diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index 0fbe5de..d41ba25 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -5,14 +5,14 @@ bundle: version: "0.4.0-dev" release_sequence: 0 channel: "development" - source_tree_digest: "sha256:017d389794f2e9f7bba28e18cf4dd61c51ed56eef6eb2600e4c8f8b919bce854" - digest: "sha256:9eb5f71d3114d6e5364d996075e8ab12703223c9c5f0bab02af42eef4e9b7b80" + source_tree_digest: "sha256:a18f15d00b467f9585fecf7ee3230f00025f69726e17135f14eb99f3036b5827" + digest: "sha256:7d370fff3455208269852d3bd0f7e4eff1e61b3eb8e80df9c967245bf464cda5" projection: - input_digest: "sha256:cc7b9a4f7c11ad8b00708e56ea2afbe3a33f65f0295baad7ef98b636cb9c16ef" - output_digest: "sha256:a241cbac1606ef94318d54a078c5ebcc6dcc69dbf828676b9dc36f0914016958" + input_digest: "sha256:88f5073a543617f2b37990db826b8777ef8c396eb658de403f60e2e480e15f62" + output_digest: "sha256:d76ab0b017214dce592c2ae8b23e30b89caabf5a0197be3b25dae6d948cd00aa" files: - path: ".gds/compiled-policy.json" digest: "sha256:78d09606bb4168d74bce1f50ab62b46a7ded34652c6b23af1badfd26dd060e94" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:c77919497087a60415fed43024d50a3a0d5c69d4cb5b4ed324db443c772ccf93" + digest: "sha256:1508f13759d4a238114e35d7c6ee6e0f6d2b3b09e18d8fb7eff46b534143cb73" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index f47b44c..b25364c 100644 --- a/.github/workflows/gds-ci.yml +++ b/.github/workflows/gds-ci.yml @@ -1,8 +1,8 @@ # GENERATED FILE - DO NOT EDIT DIRECTLY # generator: gds # bundle: 0.4.0-dev -# source-tree-digest: sha256:017d389794f2e9f7bba28e18cf4dd61c51ed56eef6eb2600e4c8f8b919bce854 -# input-digest: sha256:cc7b9a4f7c11ad8b00708e56ea2afbe3a33f65f0295baad7ef98b636cb9c16ef +# source-tree-digest: sha256:a18f15d00b467f9585fecf7ee3230f00025f69726e17135f14eb99f3036b5827 +# input-digest: sha256:88f5073a543617f2b37990db826b8777ef8c396eb658de403f60e2e480e15f62 # output-digest: sha256:01fb4854784be9e4564bcc84e70786484b370879be5e5ab1dd49f8b73ea2dea4 # edit-source: # - .gds/repository.yaml diff --git a/core/app/services.go b/core/app/services.go index 4eade3b..58d8b7d 100644 --- a/core/app/services.go +++ b/core/app/services.go @@ -810,8 +810,13 @@ func (services *Services) ValidateRepository(ctx context.Context, path string) d } } class := classifyFindings(findings) + // The schema digest travels with the result so a pass can be attributed to + // the schema revision that produced it. The binary's version string cannot do + // that: it is stamped at build time and may name a revision that was never + // released. envelope := domain.NewEnvelope("gds validate repository", class, map[string]any{ "target": info.WorktreeRoot, "anchor": anchorPath, + "schema_digest": services.Schemas.Digest(), }, findings...) return envelope } diff --git a/core/validation/schema.go b/core/validation/schema.go index 11254df..414bb45 100644 --- a/core/validation/schema.go +++ b/core/validation/schema.go @@ -4,6 +4,7 @@ package validation import ( "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -26,6 +27,7 @@ import ( type Set struct { compiled map[string]*jsonschema.Schema + digest string } type offlineLoader struct{} @@ -79,6 +81,11 @@ func NewSchemaSet() (*Set, error) { doc any } resources := []resource{} + // The digest identifies the schema revision a binary actually validates + // against. A version string cannot: it is stamped at build time and can name + // a revision that was never released, so a caller printing it beside a green + // result reports a name with no contract behind it. + digest := sha256.New() for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".schema.json") { continue @@ -92,6 +99,10 @@ func NewSchemaSet() (*Set, error) { if err != nil { return nil, fmt.Errorf("decode embedded schema %s: %w", path, err) } + digest.Write([]byte(path)) + digest.Write([]byte{0}) + digest.Write(raw) + digest.Write([]byte{0}) object, ok := doc.(map[string]any) if !ok { return nil, fmt.Errorf("schema %s root is not an object", path) @@ -121,9 +132,14 @@ func NewSchemaSet() (*Set, error) { } compiled[resource.name] = schema } - return &Set{compiled: compiled}, nil + return &Set{compiled: compiled, digest: "sha256:" + hex.EncodeToString(digest.Sum(nil))}, nil } +// Digest returns the content digest of the embedded schema set this binary +// compiled. Callers record it beside a validation result so a pass can be tied +// to the schema revision that produced it. +func (set *Set) Digest() string { return set.digest } + func (set *Set) Names() []string { names := make([]string, 0, len(set.compiled)) for name := range set.compiled { diff --git a/core/validation/schema_test.go b/core/validation/schema_test.go index 7a90b2e..6cd1b1b 100644 --- a/core/validation/schema_test.go +++ b/core/validation/schema_test.go @@ -1,15 +1,20 @@ package validation import ( + "crypto/sha256" + "encoding/hex" "encoding/json" + "io/fs" "path/filepath" "runtime" + "strings" "sync" "sync/atomic" "testing" "github.com/NDDev-OpenNetwork/github-device-sync/core/domain" "github.com/NDDev-OpenNetwork/github-device-sync/core/serialization" + gdsschemas "github.com/NDDev-OpenNetwork/github-device-sync/schemas" ) func TestEmbeddedSchemasCompile(t *testing.T) { @@ -117,3 +122,69 @@ func toJSONValue(t *testing.T, value any) any { } return decoded } + +// TestSchemaDigestIdentifiesTheEmbeddedSet covers what a version string cannot: +// the digest must name the schema revision this binary compiled, so a caller can +// tie a green validation to the schemas that produced it. A build-time version +// can name a revision that was never released, which is why the digest is +// derived from content rather than stamped. +func TestSchemaDigestIdentifiesTheEmbeddedSet(t *testing.T) { + t.Parallel() + first, err := NewSchemaSet() + if err != nil { + t.Fatalf("NewSchemaSet() error = %v", err) + } + digest := first.Digest() + if !strings.HasPrefix(digest, "sha256:") || len(digest) != len("sha256:")+64 { + t.Fatalf("Digest() = %q, want a sha256: prefix and 64 hex characters", digest) + } + // Determinism matters more than the value: two sets compiled from the same + // embedded schemas must agree, or the digest identifies the run instead of + // the schemas and is worse than printing nothing. + second, err := NewSchemaSet() + if err != nil { + t.Fatalf("NewSchemaSet() second call error = %v", err) + } + if second.Digest() != digest { + t.Fatalf("Digest() is not deterministic: %q then %q", digest, second.Digest()) + } +} + +// TestSchemaDigestCoversEverySchemaFile guards the direction the digest exists +// to guard. A digest that skipped a file would stay identical while that file +// changed, which is the failure it is meant to make impossible. +func TestSchemaDigestCoversEverySchemaFile(t *testing.T) { + t.Parallel() + set, err := NewSchemaSet() + if err != nil { + t.Fatalf("NewSchemaSet() error = %v", err) + } + entries, err := fs.ReadDir(gdsschemas.V1, "v1") + if err != nil { + t.Fatalf("ReadDir() error = %v", err) + } + expected := sha256.New() + files := 0 + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".schema.json") { + continue + } + path := "v1/" + entry.Name() + raw, err := gdsschemas.V1.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", path, err) + } + expected.Write([]byte(path)) + expected.Write([]byte{0}) + expected.Write(raw) + expected.Write([]byte{0}) + files++ + } + if files == 0 { + t.Fatal("no embedded schema files found; the digest would be vacuous") + } + want := "sha256:" + hex.EncodeToString(expected.Sum(nil)) + if set.Digest() != want { + t.Fatalf("Digest() = %q, want %q over %d schema files", set.Digest(), want, files) + } +}