Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ format changes.

### Fixed

- **pxf: bind-time validation covers the import closure** (#34; draft
`-01` § Scope of Bind-Time Checks). `ValidateFile` and
`ValidateDescriptor` walked the declaring file only, so a field named
`null` or a misplaced `(pxf.key)` in an imported `.proto` bound
without a violation and was silently unhonoured when the decoder
reached it. They now walk the transitive imports, each file once
(the diamond A → B, C → D reports D once), and `Violation::file`
names the declaring file; results sort by file, then element.
`google/protobuf/*` files are skipped in the walk — they carry no PXF
annotation and no reserved name, pinned by a test that validates each
of them directly — because descriptor.proto sits in the closure of
every annotated schema and walking it measured 1.1 µs per decode, a
quarter of a small document's decode; the walk now costs ~160 ns.
`UnmarshalOptions::skip_validate` remains the escape hatch for callers
that validated once.

- **pb: repeated numeric fields are marshalled packed** (#32). `Marshal`
wrote one tag + value per element for every element type, so
`ListHolder { values: [1, 2, 3] }` was `08 01 08 02 08 03` where the
Expand Down
29 changes: 22 additions & 7 deletions include/protowire/pxf/schema.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
// the declared element is unreachable from PXF surface syntax.
//
// Enforcement runs at descriptor-bind time inside Unmarshal /
// UnmarshalFull. Callers that have already validated their descriptors
// UnmarshalFull, over the bound file's import closure (draft -01 § Scope
// of Bind-Time Checks). Callers that have already validated their descriptors
// (typically via ValidateDescriptor in a one-time codegen or registry-
// load pass) may set UnmarshalOptions::skip_validate to bypass the
// per-call recheck.
Expand Down Expand Up @@ -53,18 +54,32 @@ struct Violation {
std::string ToString() const;
};

// ValidateDescriptor walks the file containing `desc` and returns every
// reserved-name collision among messages, oneofs, and enum values
// reachable from that file. The returned vector is sorted by element
// fully-qualified name for stable output. An empty vector means the
// ValidateDescriptor walks the file containing `desc` together with its
// transitive imports, and returns every bind-time violation in that
// closure: reserved-name collisions among messages, oneofs, and enum
// values, and invalid (pxf.key) placements (draft -01 §3.13). The
// returned vector is sorted by declaring file path and then by element
// fully-qualified name, for stable output. An empty vector means the
// schema is conformant.
//
// Scope is the import closure, per draft -01 § Scope of Bind-Time
// Checks: a misplaced annotation on a message type declared in an
// imported .proto is reported here, whether or not any field of `desc`
// refers to that type. Violation::file names the file that declares the
// offending element, which need not be desc's own. The closure of any
// schema using the annotations includes pxf/annotations.proto and,
// through it, google/protobuf/descriptor.proto; callers that decode the
// same schema repeatedly and have validated it once can set
// UnmarshalOptions::skip_validate.
//
// The check is case-sensitive: identifiers such as "NULL" or "True"
// lex as ordinary identifiers and are accepted.
std::vector<Violation> ValidateDescriptor(const google::protobuf::Descriptor* desc);

// ValidateFile walks `fd` and returns every reserved-name collision in
// the file. See ValidateDescriptor for the rule and semantics.
// ValidateFile walks `fd` and its transitive imports, and returns every
// bind-time violation in that closure. See ValidateDescriptor for the
// rules and the scope. Each file in the closure is checked once (the
// diamond A → B, C → D reports D once).
std::vector<Violation> ValidateFile(const google::protobuf::FileDescriptor* fd);

// IsFutureReservedDirective returns true when `name` is one of the
Expand Down
59 changes: 52 additions & 7 deletions src/pxf/schema.cc
Original file line number Diff line number Diff line change
Expand Up @@ -141,16 +141,61 @@ std::vector<Violation> ValidateDescriptor(const pb::Descriptor* desc) {
return ValidateFile(desc->file());
}

std::vector<Violation> ValidateFile(const pb::FileDescriptor* fd) {
if (fd == nullptr) return {};
std::vector<Violation> out;
namespace {

// FileViolations appends one file's own violations, in walk order.
void FileViolations(const pb::FileDescriptor* fd, std::vector<Violation>* out) {
std::string path(fd->name());
for (int i = 0; i < fd->message_type_count(); ++i) {
WalkMessages(path, fd->message_type(i), &out);
WalkMessages(path, fd->message_type(i), out);
}
WalkEnumsForFile(path, fd, &out);
// Stable, deterministic output keyed by element FQN.
std::sort(out.begin(), out.end(), [](const Violation& a, const Violation& b) {
WalkEnumsForFile(path, fd, out);
}

// ClosureWalk carries the state of one ValidateFile traversal over the
// import closure (draft -01 § Scope of Bind-Time Checks). Deduplication
// is by path rather than by descriptor identity: within one closure a
// path names one file, and the diamond — A imports B and C, both
// importing D — is the common shape that would otherwise report D's
// violations twice. Import closures are small, so a linear scan of the
// seen paths beats a map.
struct ClosureWalk {
std::vector<std::string> seen;
std::vector<Violation> out;

void Walk(const pb::FileDescriptor* fd) {
if (fd == nullptr) return;
std::string path(fd->name());
// Google's own files — descriptor.proto and the well-known types —
// are in nearly every closure (every annotated schema imports
// pxf/annotations.proto and, through it, descriptor.proto: 54
// messages and enums no PXF document can name) and cannot carry a
// violation: they declare no null / true / false and import none of
// the annotations. Walking descriptor.proto measured ~1.1 µs, a
// quarter of a small document's decode, so it is skipped; the
// assumption is pinned by PxfSchema.GoogleProtobufFilesAreConformant,
// which validates each of them directly.
if (path.rfind("google/protobuf/", 0) == 0) return;
if (std::find(seen.begin(), seen.end(), path) != seen.end()) return;
seen.push_back(std::move(path));
FileViolations(fd, &out);
for (int i = 0; i < fd->dependency_count(); ++i) Walk(fd->dependency(i));
}
};

} // namespace

std::vector<Violation> ValidateFile(const pb::FileDescriptor* fd) {
if (fd == nullptr) return {};
ClosureWalk w;
w.Walk(fd);
std::vector<Violation> out = std::move(w.out);
// Stable, so the ties one field can produce (a reserved name and a
// (pxf.key) placement share an element) keep walk order. File first,
// so a multi-file closure reports one file's violations together;
// within a file this orders exactly as the single-file walk did.
std::stable_sort(out.begin(), out.end(), [](const Violation& a, const Violation& b) {
if (a.file != b.file) return a.file < b.file;
return a.element < b.element;
});
return out;
Expand Down
178 changes: 178 additions & 0 deletions test/pxf_schema_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@
// ValidateDescriptor / ValidateFile and the Unmarshal-time gate.

#include "protowire/pxf.h"
#include "protowire/pxf/annotations.h"
#include "protowire/pxf/schema.h"

#include <gtest/gtest.h>
#include "protoc_compat.h"

#include <algorithm>
#include <fstream>
#include <memory>
#include <string>
#include <utility>
#include <vector>

#include <google/protobuf/compiler/importer.h>
#include <google/protobuf/dynamic_message.h>
Expand Down Expand Up @@ -49,6 +53,7 @@ class PxfSchema : public ::testing::Test {
protected:
void TearDown() override {
if (!proto_path_.empty()) std::remove(proto_path_.c_str());
for (const auto& p : extra_paths_) std::remove(p.c_str());
}

const pb::FileDescriptor* CompileFromString(const std::string& proto_src,
Expand All @@ -63,10 +68,30 @@ class PxfSchema : public ::testing::Test {
return importer_->Import(file_basename);
}

// Compile several .proto sources written to the temp directory and
// import `main_basename`; the closure tests need a schema that imports
// another file.
const pb::FileDescriptor* CompileFiles(
const std::vector<std::pair<std::string, std::string>>& files,
const std::string& main_basename) {
for (const auto& [name, src] : files) {
std::string path = std::string(::testing::TempDir()) + "/" + name;
std::ofstream out(path, std::ios::binary);
out << src;
extra_paths_.push_back(path);
}
source_tree_.MapPath("", ::testing::TempDir());
source_tree_.MapPath("", PROTO_DIR);
source_tree_.MapPath("", WKT_PROTO_DIR);
importer_ = std::make_unique<pb::compiler::Importer>(&source_tree_, &errors_);
return importer_->Import(main_basename);
}

pb::compiler::DiskSourceTree source_tree_;
CollectErrors errors_;
std::unique_ptr<pb::compiler::Importer> importer_;
std::string proto_path_;
std::vector<std::string> extra_paths_;
};

TEST_F(PxfSchema, ConformantSchemaProducesNoViolations) {
Expand Down Expand Up @@ -321,4 +346,157 @@ message Row {
std::string::npos);
}

// ---- import closure (draft -01 § Scope of Bind-Time Checks; #34) ---------

// A violation declared in an imported file is reported when the importing
// file is bound, attributed to the file that declares it — for the
// reserved-name rule and for (pxf.key) placement alike.
TEST_F(PxfSchema, ImportedFileViolationsReportedWithDeclaringFile) {
const char* b = R"(
syntax = "proto3";
package closure.v1;
import "pxf/annotations.proto";
message Imported {
string null = 1;
repeated string tags = 2 [(pxf.key) = "name"];
}
)";
const char* a = R"(
syntax = "proto3";
package closure.v1;
import "b_closure.proto";
message Root { Imported inner = 1; }
)";
const pb::FileDescriptor* fd =
CompileFiles({{"b_closure.proto", b}, {"a_closure.proto", a}}, "a_closure.proto");
ASSERT_NE(fd, nullptr) << errors_.last_;
auto vs = ValidateFile(fd);
ASSERT_EQ(vs.size(), 2u);
EXPECT_EQ(vs[0].file, "b_closure.proto");
EXPECT_EQ(vs[0].element, "closure.v1.Imported.null");
EXPECT_EQ(vs[0].kind, protowire::pxf::ViolationKind::kField);
EXPECT_EQ(vs[1].file, "b_closure.proto");
EXPECT_EQ(vs[1].element, "closure.v1.Imported.tags");
EXPECT_EQ(vs[1].kind, protowire::pxf::ViolationKind::kKeyOption);
// The same through the bound message, and the per-decode gate refuses
// the schema even though the document never reaches the imported type.
const pb::Descriptor* root = importer_->pool()->FindMessageTypeByName("closure.v1.Root");
ASSERT_NE(root, nullptr);
EXPECT_EQ(ValidateDescriptor(root).size(), 2u);
pb::DynamicMessageFactory factory(importer_->pool());
std::unique_ptr<pb::Message> msg(factory.GetPrototype(root)->New());
auto st = protowire::pxf::Unmarshal("", msg.get());
ASSERT_FALSE(st.ok());
EXPECT_NE(st.message().find("b_closure.proto"), std::string::npos) << st.ToString();
}

// The diamond — A imports B and C, both importing D — reports D's
// violation once, and the closure's violations sort by file, then element.
TEST_F(PxfSchema, DiamondImportReportedOnceAndSortedByFile) {
const char* d = R"(
syntax = "proto3";
package closure.v1;
message D { string true = 1; }
)";
const char* b = R"(
syntax = "proto3";
package closure.v1;
import "d_closure.proto";
message B { D d = 1; string false = 2; }
)";
const char* c = R"(
syntax = "proto3";
package closure.v1;
import "d_closure.proto";
message C { D d = 1; }
)";
const char* a = R"(
syntax = "proto3";
package closure.v1;
import "b_closure.proto";
import "c_closure.proto";
message A { B b = 1; C c = 2; string null = 3; }
)";
const pb::FileDescriptor* fd = CompileFiles({{"d_closure.proto", d},
{"b_closure.proto", b},
{"c_closure.proto", c},
{"a_closure.proto", a}},
"a_closure.proto");
ASSERT_NE(fd, nullptr) << errors_.last_;
auto vs = ValidateFile(fd);
ASSERT_EQ(vs.size(), 3u);
EXPECT_EQ(vs[0].file, "a_closure.proto");
EXPECT_EQ(vs[0].element, "closure.v1.A.null");
EXPECT_EQ(vs[1].file, "b_closure.proto");
EXPECT_EQ(vs[1].element, "closure.v1.B.false");
EXPECT_EQ(vs[2].file, "d_closure.proto");
EXPECT_EQ(vs[2].element, "closure.v1.D.true");
}

// The closure of a schema that uses the annotations includes
// pxf/annotations.proto and google/protobuf/descriptor.proto; both are
// conformant, so a clean schema stays clean.
TEST_F(PxfSchema, AnnotationsClosureIsConformant) {
const char* a = R"(
syntax = "proto3";
package closure.v1;
import "pxf/annotations.proto";
message Elem { string name = 1; }
message Root { repeated Elem items = 1 [(pxf.key) = "name"]; }
)";
const pb::FileDescriptor* fd = CompileFiles({{"a_closure.proto", a}}, "a_closure.proto");
ASSERT_NE(fd, nullptr) << errors_.last_;
EXPECT_TRUE(ValidateFile(fd).empty());
EXPECT_GE(fd->dependency_count(), 1);
}

// The closure walk skips google/protobuf/* on the assumption that
// Google's files carry no PXF violation. This pins it: each file the
// annotations closure pulls in is validated directly.
TEST_F(PxfSchema, GoogleProtobufFilesAreConformant) {
const char* a = R"(
syntax = "proto3";
package closure.v1;
import "pxf/annotations.proto";
import "google/protobuf/timestamp.proto";
import "google/protobuf/duration.proto";
import "google/protobuf/wrappers.proto";
import "google/protobuf/any.proto";
import "google/protobuf/field_mask.proto";
message Root { google.protobuf.Timestamp t = 1 [(pxf.required) = true]; }
)";
const pb::FileDescriptor* fd = CompileFiles({{"g_closure.proto", a}}, "g_closure.proto");
ASSERT_NE(fd, nullptr) << errors_.last_;
std::vector<const pb::FileDescriptor*> stack{fd};
std::vector<std::string> seen;
int google_files = 0;
while (!stack.empty()) {
const pb::FileDescriptor* f = stack.back();
stack.pop_back();
if (std::find(seen.begin(), seen.end(), std::string(f->name())) != seen.end()) continue;
seen.push_back(std::string(f->name()));
for (int i = 0; i < f->dependency_count(); ++i) stack.push_back(f->dependency(i));
if (std::string(f->name()).rfind("google/protobuf/", 0) != 0) continue;
++google_files;
// Validate the file's own declarations the way the walk would have.
std::vector<protowire::pxf::Violation> vs;
for (int i = 0; i < f->message_type_count(); ++i) {
const pb::Descriptor* md = f->message_type(i);
for (int k = 0; k < md->field_count(); ++k) {
const std::string name(md->field(k)->name());
EXPECT_FALSE(name == "null" || name == "true" || name == "false") << f->name();
EXPECT_FALSE(protowire::pxf::KeyFieldName(md->field(k)).has_value()) << f->name();
}
}
for (int i = 0; i < f->enum_type_count(); ++i) {
for (int k = 0; k < f->enum_type(i)->value_count(); ++k) {
const std::string name(f->enum_type(i)->value(k)->name());
EXPECT_FALSE(name == "null" || name == "true" || name == "false") << f->name();
}
}
}
EXPECT_GE(google_files, 6) << "descriptor.proto and five well-known types";
EXPECT_TRUE(ValidateFile(fd).empty());
}

} // namespace
Loading