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
10 changes: 5 additions & 5 deletions .gds/bundle.lock.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 2 additions & 2 deletions .github/workflows/gds-ci.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 5 additions & 0 deletions core/app/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
18 changes: 17 additions & 1 deletion core/validation/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package validation

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
Expand All @@ -26,6 +27,7 @@ import (

type Set struct {
compiled map[string]*jsonschema.Schema
digest string
}

type offlineLoader struct{}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
71 changes: 71 additions & 0 deletions core/validation/schema_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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)
}
}