diff --git a/docs/PORTABLE_TOOL_DEFINITION_IMPLEMENTATION_PLAN.md b/docs/PORTABLE_TOOL_DEFINITION_IMPLEMENTATION_PLAN.md index 3af8f1d9..c6cfe2ce 100644 --- a/docs/PORTABLE_TOOL_DEFINITION_IMPLEMENTATION_PLAN.md +++ b/docs/PORTABLE_TOOL_DEFINITION_IMPLEMENTATION_PLAN.md @@ -383,12 +383,12 @@ Non-goals: filesystem catalog discovery or acquisition. ### PTD-07: Load Bounded Hierarchical Portable Tool Catalogs Scope: add injected-filesystem loading, ownership and namespace discovery, -reference edge and depth limits, and duplicate/digest checks. The normative +reference-edge depth limits, and duplicate/digest checks. The normative design defines no aggregate byte or record-count ceiling, so this slice adds none, and `bounded` here does not mean a ceiling on catalog size. It means three properties that hold however large the catalog is: each record's parse is bounded by the per-unit limits before that record is decoded; traversal and -recursion are bounded by the edge and depth limits; and no allocation, buffer, +recursion are bounded by the reference-edge depth limit; and no allocation, buffer, or traversal is ever sized by a count a record declares, only by content the loader has already observed. Loading a catalog of `n` records therefore costs work and retention linear in `n`, which is the operator's own embedded input, diff --git a/internal/toolcatalog/catalog.go b/internal/toolcatalog/catalog.go new file mode 100644 index 00000000..f928f35b --- /dev/null +++ b/internal/toolcatalog/catalog.go @@ -0,0 +1,406 @@ +package toolcatalog + +import ( + "fmt" + + "github.com/omry/reploy/internal/canonical" + "io" + "io/fs" + "path" + "sort" + "strings" +) + +// Bounded hierarchical loading for portable tool catalogs. +// +// `bounded` here does not mean a ceiling on catalog size. The normative design +// defines no aggregate byte, record-count, or reference-edge ceiling, so this +// loader declares none. It means three properties that hold however large the +// catalog is: +// +// - each record's parse is bounded by the per-unit limits before that record +// is decoded, so one malformed file cannot exhaust the parser; +// - reference traversal and recursion are bounded by the graph depth limit; +// - no allocation, buffer, or traversal is ever sized by a count a record +// declares, only by content the loader has already observed. +// +// Loading a catalog of n records therefore costs work and retention linear in +// n, which is the operator's own embedded input, rather than work a record can +// inflate. +const maxCatalogGraphDepthV1 = 64 + +// recordKeyV1 is a record's exact identity. The design permits one record ID to +// appear at different digests when separate immutable release revisions select +// them, so the catalog index cannot be keyed by ID alone. +type recordKeyV1 struct { + ID string + Digest canonical.Digest +} + +// CatalogV1 is an immutable set of portable tool records indexed by exact +// identity, with the tool records reachable by qualified tool name. +type CatalogV1 struct { + records map[recordKeyV1]loadedRecordV1 + tools map[string]recordKeyV1 +} + +// Names lists the qualified tool names this catalog defines, in canonical order. +func (catalog *CatalogV1) Names() []string { + names := make([]string, 0, len(catalog.tools)) + for name := range catalog.tools { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// loadCatalogV1 loads every record below root in the injected filesystem. +func loadCatalogV1(files fs.FS, root string) (*CatalogV1, error) { + catalog := &CatalogV1{ + records: make(map[recordKeyV1]loadedRecordV1), + tools: make(map[string]recordKeyV1), + } + err := fs.WalkDir(files, root, func(filename string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + if path.Ext(filename) != ".json" { + return fmt.Errorf("catalog entry %q must be a JSON file", filename) + } + payload, err := readCatalogRecordV1(files, filename) + if err != nil { + return err + } + // Per-record limits apply inside decodeRecordV1 before the record is + // decoded, so a hostile file is rejected without the loader having to + // know anything about the catalog as a whole. + record, err := decodeRecordV1(filename, payload) + if err != nil { + return err + } + if err := catalog.placeRecordV1(record, filename, root); err != nil { + return err + } + return nil + }) + if err != nil { + return nil, err + } + if len(catalog.records) == 0 || len(catalog.tools) == 0 { + return nil, fmt.Errorf("portable tool catalog is empty") + } + if err := catalog.verifyReferenceDepthV1(); err != nil { + return nil, err + } + if err := catalog.validateReleaseGraphsV1(); err != nil { + return nil, err + } + return catalog, nil +} + +// placeRecordV1 enforces that a record lives below the tool namespace its own +// ID declares, so a record cannot be introduced under another tool's ownership. +func (catalog *CatalogV1) placeRecordV1(record loadedRecordV1, filename string, root string) error { + relative := filename + if trimmed := strings.TrimSuffix(root, "/"); trimmed != "" && trimmed != "." { + relative = strings.TrimPrefix(filename, trimmed+"/") + if relative == filename { + return fmt.Errorf("catalog entry %q must live below %q", filename, root) + } + } + toolName, err := recordToolNameV1(record.ID) + if err != nil { + return fmt.Errorf("catalog entry %q: %w", filename, err) + } + if !strings.HasPrefix(relative, toolName+"/") { + return fmt.Errorf("catalog entry %q must live below %q", filename, toolName) + } + // Two files describing the exact same (id, digest) pair are a duplicate + // definition. The same ID at a different digest is legal and belongs to a + // different immutable release revision. + key := recordKeyV1{ID: record.ID, Digest: record.Digest} + if _, exists := catalog.records[key]; exists { + return fmt.Errorf("catalog contains duplicate definition of %q at digest %s", record.ID, record.Digest) + } + if record.Schema == ToolRecordSchemaV1 { + if relative != toolName+"/tool.json" { + return fmt.Errorf("tool record %q must use path %q", record.ID, toolName+"/tool.json") + } + if _, exists := catalog.tools[toolName]; exists { + return fmt.Errorf("catalog contains duplicate tool %q", toolName) + } + catalog.tools[toolName] = key + } + catalog.records[key] = record + return nil +} + +// readCatalogRecordV1 reads one record file. The read is bounded by the same +// per-record byte limit decoding applies, so a single oversized file fails here +// rather than after the whole file is resident. +func readCatalogRecordV1(files fs.FS, filename string) ([]byte, error) { + file, err := files.Open(filename) + if err != nil { + return nil, fmt.Errorf("catalog entry %q: %w", filename, err) + } + defer file.Close() + payload, err := io.ReadAll(io.LimitReader(file, maxDefinitionFileBytes+1)) + if err != nil { + return nil, fmt.Errorf("catalog entry %q: %w", filename, err) + } + if len(payload) > maxDefinitionFileBytes { + return nil, fmt.Errorf("catalog entry %q exceeds %d bytes", filename, maxDefinitionFileBytes) + } + return payload, nil +} + +// recordToolNameV1 extracts the qualified tool name a record ID declares. +func recordToolNameV1(id string) (string, error) { + segments := strings.Split(id, "/") + name, found := strings.CutPrefix(segments[0], "tool:") + if !found || !validRecordIdentifierV1(name) { + return "", fmt.Errorf("record ID %q does not declare a qualified tool name", id) + } + return name, nil +} + +// verifyReferenceDepthV1 bounds recursion rather than catalog size: it walks +// every record's references and fails when a chain exceeds the graph depth +// limit, which is what stops a cyclic or pathologically deep definition from +// exhausting the stack. It never allocates from a declared count. +func (catalog *CatalogV1) verifyReferenceDepthV1() error { + visiting := make(map[recordKeyV1]bool, len(catalog.records)) + // Memoize each settled node's longest remaining chain, not merely that it + // was visited. Recording only visitation lets a node reached first from a + // short prefix report zero remaining depth, so a later walk through a long + // prefix would clear the bound it should have failed. + suffix := make(map[recordKeyV1]int, len(catalog.records)) + var walk func(key recordKeyV1, depth int) (int, error) + walk = func(key recordKeyV1, depth int) (int, error) { + if depth > maxCatalogGraphDepthV1 { + return 0, fmt.Errorf("catalog reference chain through %q exceeds depth %d", key.ID, maxCatalogGraphDepthV1) + } + if known, done := suffix[key]; done { + if depth+known > maxCatalogGraphDepthV1 { + return 0, fmt.Errorf("catalog reference chain through %q exceeds depth %d", key.ID, maxCatalogGraphDepthV1) + } + return known, nil + } + if visiting[key] { + return 0, fmt.Errorf("catalog references form a cycle through %q", key.ID) + } + record, exists := catalog.records[key] + if !exists { + return 0, nil + } + visiting[key] = true + deepest := 0 + for _, edge := range catalogReferencesV1(record.Value) { + below, err := walk(recordKeyV1{ID: edge.Reference.ID, Digest: edge.Reference.Digest}, depth+1) + if err != nil { + return 0, err + } + if below+1 > deepest { + deepest = below + 1 + } + } + visiting[key] = false + suffix[key] = deepest + return deepest, nil + } + for _, key := range catalog.sortedRecordKeysV1() { + if _, err := walk(key, 0); err != nil { + return err + } + } + return nil +} + +// validateReleaseGraphsV1 gives the release graph walker a production caller: +// every tool record's release index is validated, and every manifest it names +// has its resolved graph validated against the records actually loaded. +func (catalog *CatalogV1) validateReleaseGraphsV1() error { + for _, toolName := range catalog.Names() { + toolKey := catalog.tools[toolName] + record := catalog.records[toolKey] + tool, ok := record.Value.(*ToolRecordV1) + if !ok { + return fmt.Errorf("tool %q does not resolve to a tool record", toolName) + } + // The release index resolves the tool's manifest references only. + // Traversing their closures and merging them would put two revisions' + // records into one ID-keyed view, where differing digests of the same + // semantic record collide even though they belong to separate release + // graphs, which is exactly what the design permits. + index, err := catalog.releaseIndexViewV1(toolKey, tool) + if err != nil { + return fmt.Errorf("tool %q: %w", toolName, err) + } + if err := validateToolReleaseIndexV1(tool, index); err != nil { + return fmt.Errorf("tool %q: %w", toolName, err) + } + for _, reference := range tool.Releases { + manifestKey := recordKeyV1{ID: reference.ID, Digest: reference.Digest} + view, err := catalog.resolvedViewV1(manifestKey) + if err != nil { + return fmt.Errorf("tool %q: %w", toolName, err) + } + manifestRecord, err := resolvedRecordV1(view, reference) + if err != nil { + return fmt.Errorf("tool %q: %w", toolName, err) + } + manifest, ok := manifestRecord.Value.(*ReleaseManifestV1) + if !ok { + return fmt.Errorf("tool %q release %q is not a manifest", toolName, reference.ID) + } + if err := validateManifestResolvedGraphV1(manifest, view); err != nil { + return fmt.Errorf("tool %q release %q: %w", toolName, manifest.ID, err) + } + } + } + return nil +} + +// sortedRecordIDsV1 gives traversal a deterministic order, so a defect is +// reported identically on every run rather than depending on map iteration. +func (catalog *CatalogV1) sortedRecordKeysV1() []recordKeyV1 { + keys := make([]recordKeyV1, 0, len(catalog.records)) + for key := range catalog.records { + keys = append(keys, key) + } + sort.Slice(keys, func(left int, right int) bool { + if keys[left].ID != keys[right].ID { + return keys[left].ID < keys[right].ID + } + return keys[left].Digest < keys[right].Digest + }) + return keys +} + +// releaseIndexViewV1 builds the shallow view the release index needs: the tool +// record and the exact manifests it names, with no transitive closure. Each +// manifest's own graph is validated separately against its own view. +func (catalog *CatalogV1) releaseIndexViewV1(toolKey recordKeyV1, tool *ToolRecordV1) (map[string]loadedRecordV1, error) { + view := map[string]loadedRecordV1{toolKey.ID: catalog.records[toolKey]} + for _, reference := range tool.Releases { + key := recordKeyV1{ID: reference.ID, Digest: reference.Digest} + record, exists := catalog.records[key] + if !exists { + return nil, fmt.Errorf("release %q at digest %s is not in the catalog", reference.ID, reference.Digest) + } + if previous, seen := view[reference.ID]; seen && previous.Digest != reference.Digest { + return nil, fmt.Errorf("release index names %q at two digests, %s and %s", + reference.ID, previous.Digest, reference.Digest) + } + view[reference.ID] = record + } + return view, nil +} + +// resolvedViewV1 projects the exact records one manifest selects into an +// ID-keyed view, which is what the release graph walker consumes. Two digests +// for one ID inside a single resolved graph is an error, so the projection +// fails rather than choosing between them. +func (catalog *CatalogV1) resolvedViewV1(root recordKeyV1) (map[string]loadedRecordV1, error) { + view := make(map[string]loadedRecordV1) + var walk func(key recordKeyV1, depth int) error + walk = func(key recordKeyV1, depth int) error { + if depth > maxCatalogGraphDepthV1 { + return fmt.Errorf("resolved graph through %q exceeds depth %d", key.ID, maxCatalogGraphDepthV1) + } + record, exists := catalog.records[key] + if !exists { + return nil + } + if previous, seen := view[key.ID]; seen { + if previous.Digest != key.Digest { + return fmt.Errorf("resolved graph selects %q at two digests, %s and %s", + key.ID, previous.Digest, key.Digest) + } + return nil + } + view[key.ID] = record + for _, edge := range catalogReferencesV1(record.Value) { + if err := walk(recordKeyV1{ID: edge.Reference.ID, Digest: edge.Reference.Digest}, depth+1); err != nil { + return err + } + } + return nil + } + if err := walk(root, 0); err != nil { + return nil, err + } + return view, nil +} + +// catalogReferenceV1 is one outgoing reference and the record schemas that may +// legitimately satisfy it. +type catalogReferenceV1 struct { + Reference RecordReferenceV1 + Schemas []string +} + +// catalogReferencesV1 enumerates every outgoing reference a record declares. +// +// The parked source enumerated a singular integration fixture and a singular +// binding artifact per binding. The accepted model is plural on both, and adds +// binding- and selection-scoped package sets, so enumerating the parked shape +// would silently omit edges from traversal and from any check built on it. +func catalogReferencesV1(value any) []catalogReferenceV1 { + ref := func(reference RecordReferenceV1, schemas ...string) catalogReferenceV1 { + return catalogReferenceV1{Reference: reference, Schemas: schemas} + } + result := []catalogReferenceV1{} + switch record := value.(type) { + case *ToolRecordV1: + for _, reference := range record.Releases { + result = append(result, ref(reference, ReleaseManifestSchemaV1)) + } + case *ReleaseManifestV1: + result = append(result, + ref(record.Contract, ReleaseContractSchemaV1), + ref(record.ValidationProfile, ValidationProfileSchemaV1)) + for _, reference := range record.Targets { + result = append(result, ref(reference, TargetRecordSchemaV1)) + } + for _, mapping := range record.ArtifactSources { + result = append(result, + ref(mapping.Artifact, BindingArtifactSchemaV1, PayloadRecordSchemaV1), + ref(mapping.Source, ArtifactSourceRecordSchemaV1)) + } + case *TargetRecordV1: + result = append(result, ref(record.ValidationProfile, ValidationProfileSchemaV1)) + for _, reference := range record.IntegrationFixtures { + result = append(result, ref(reference, IntegrationFixtureSchemaV1)) + } + for _, reference := range record.PackageSets { + result = append(result, ref(reference, NativePackageSetSchemaV1)) + } + for _, binding := range record.Bindings { + result = append(result, ref(binding.Contract, BindingContractSchemaV1)) + for _, reference := range binding.Artifacts { + result = append(result, ref(reference, BindingArtifactSchemaV1)) + } + for _, reference := range binding.PackageSets { + result = append(result, ref(reference, NativePackageSetSchemaV1)) + } + } + for _, reference := range record.Payloads { + result = append(result, ref(reference, PayloadRecordSchemaV1)) + } + for _, selection := range record.Selections { + for _, reference := range selection.Payloads { + result = append(result, ref(reference, PayloadRecordSchemaV1)) + } + for _, reference := range selection.PackageSets { + result = append(result, ref(reference, NativePackageSetSchemaV1)) + } + } + case *BindingArtifactRecordV1: + result = append(result, ref(record.Contract, BindingContractSchemaV1)) + } + return result +} diff --git a/internal/toolcatalog/catalog_test.go b/internal/toolcatalog/catalog_test.go new file mode 100644 index 00000000..436c48f7 --- /dev/null +++ b/internal/toolcatalog/catalog_test.go @@ -0,0 +1,454 @@ +package toolcatalog + +import ( + "encoding/json" + + "github.com/omry/reploy/internal/canonical" + "strings" + "testing" + "testing/fstest" +) + +// catalogTestFilesV1 builds an injected filesystem holding a complete, closed +// catalog. References carry the true digest of the record they name, computed +// exactly as decoding computes it, so the fixture exercises the real resolution +// path rather than a placeholder that could never appear in a real catalog. +func catalogTestFilesV1(t *testing.T) fstest.MapFS { + t.Helper() + const release = "tool:demo/releases/1.2.3" + files := fstest.MapFS{} + digests := map[string]canonical.Digest{} + + place := func(relative string, value any) canonical.Digest { + digest, err := canonical.Sum("portable-tool-record", portableToolRecordIdentityV1, value) + if err != nil { + t.Fatalf("digest %s: %v", relative, err) + } + payload, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal %s: %v", relative, err) + } + files["catalog/"+relative] = &fstest.MapFile{Data: payload} + digests[recordIDV1(value)] = digest + return digest + } + ref := func(id string) RecordReferenceV1 { + digest, found := digests[id] + if !found { + t.Fatalf("reference %q has no placed record", id) + } + return RecordReferenceV1{ID: id, Digest: digest} + } + + values := validRecordValuesV1() + // Leaves first, so every reference can carry a digest that already exists. + profile := values[10].(*ValidationProfileRecordV1) + place("demo/releases/1.2.3/validation/profiles/default.json", profile) + fixture := values[9].(*IntegrationFixtureRecordV1) + place("demo/releases/1.2.3/validation/fixtures/debian-12-amd64.json", fixture) + // The sample target advertises no binding, so a binding contract and artifact + // would be orphaned catalog data. Reachability rejects orphans, so the + // fixture stays closed rather than carrying records nothing selects. + + payload := &PayloadRecordV1{Schema: PayloadRecordSchemaV1, + ID: release + "/payloads/demo-linux-amd64", Name: "demo", + Revision: "1", UpstreamVersion: "1.2.3", Platform: "linux/amd64", + LogicalPath: "tools/demo/demo.tar.gz", Kind: "jdk-archive", + Size: "42", SHA256: recordTestDigest, Resolver: "https-sha256", + Entries: "2", UnpackedSize: "84", InstallDirectory: "demo-1", + ArchiveRoot: "demo", Executable: "demo/bin/demo"} + place("demo/releases/1.2.3/payloads/demo-linux-amd64.json", payload) + + source := *(values[7].(*ArtifactSourceRecordV1)) + source.SHA256 = recordTestDigest + source.Size = "42" + place("demo/releases/1.2.3/revisions/1/sources/demo-linux-amd64.json", &source) + + contract := values[2].(*ReleaseContractV1) + place("demo/releases/1.2.3/contract.json", contract) + + target := *(values[3].(*TargetRecordV1)) + target.ValidationProfile = ref(profile.ID) + target.IntegrationFixtures = []RecordReferenceV1{ref(fixture.ID)} + target.Payloads = []RecordReferenceV1{ref(payload.ID)} + place("demo/releases/1.2.3/targets/debian/12/amd64.json", &target) + + manifest := *(values[1].(*ReleaseManifestV1)) + manifest.Contract = ref(contract.ID) + manifest.ValidationProfile = ref(profile.ID) + manifest.Targets = []RecordReferenceV1{ref(target.ID)} + manifest.ArtifactSources = []ArtifactSourceMappingV1{{ + ArtifactSHA256: recordTestDigest, + Artifact: ref(payload.ID), + Source: ref(source.ID), + }} + place("demo/releases/1.2.3/revisions/1/manifest.json", &manifest) + + tool := *(values[0].(*ToolRecordV1)) + tool.Releases = []RecordReferenceV1{ref(manifest.ID)} + place("demo/tool.json", &tool) + return files +} + +func TestLoadCatalogAcceptsAWellFormedCatalogV1(t *testing.T) { + catalog, err := loadCatalogV1(catalogTestFilesV1(t), "catalog") + if err != nil { + t.Fatalf("a well-formed catalog was rejected: %v", err) + } + if got := strings.Join(catalog.Names(), ","); got != "demo" { + t.Errorf("catalog names = %q, want demo", got) + } + if len(catalog.records) == 0 { + t.Error("catalog loaded no records") + } +} + +func TestLoadCatalogRejectsMisplacedAndMalformedEntriesV1(t *testing.T) { + for _, testCase := range []struct { + name string + mutate func(fstest.MapFS) + wantSub string + }{ + {name: "non JSON entry", wantSub: "must be a JSON file", + mutate: func(f fstest.MapFS) { f["catalog/demo/notes.txt"] = &fstest.MapFile{Data: []byte("x")} }}, + {name: "record outside its tool namespace", wantSub: "must live below", + mutate: func(f fstest.MapFS) { + f["catalog/other/releases/1.2.3/contract.json"] = f["catalog/demo/releases/1.2.3/contract.json"] + delete(f, "catalog/demo/releases/1.2.3/contract.json") + }}, + {name: "tool record at the wrong path", wantSub: "must use path", + mutate: func(f fstest.MapFS) { + f["catalog/demo/elsewhere.json"] = f["catalog/demo/tool.json"] + delete(f, "catalog/demo/tool.json") + }}, + {name: "undecodable record", wantSub: "decode", + mutate: func(f fstest.MapFS) { + f["catalog/demo/broken.json"] = &fstest.MapFile{Data: []byte("{")} + }}, + // Record validation rejects a non-tool-qualified ID before the loader's + // own tool-name extraction is reached, so that extraction is defensive. + // Its own behaviour is covered directly by TestRecordToolNameV1. + {name: "record whose ID declares no tool", wantSub: "tool-qualified ID", + mutate: func(f fstest.MapFS) { + stray := *(validRecordValuesV1()[4].(*BindingContractV1)) + stray.ID = "notatool/bindings/python/contract" + payload, err := json.Marshal(&stray) + if err != nil { + panic(err) + } + f["catalog/demo/stray.json"] = &fstest.MapFile{Data: payload} + }}, + } { + t.Run(testCase.name, func(t *testing.T) { + files := catalogTestFilesV1(t) + testCase.mutate(files) + _, err := loadCatalogV1(files, "catalog") + if err == nil || !strings.Contains(err.Error(), testCase.wantSub) { + t.Errorf("error = %v, want substring %q", err, testCase.wantSub) + } + }) + } +} + +func TestLoadCatalogRejectsAnEmptyCatalogV1(t *testing.T) { + if _, err := loadCatalogV1(fstest.MapFS{}, "catalog"); err == nil { + t.Error("an empty catalog loaded") + } +} + +// The loader declares no aggregate ceiling, so a wide catalog is legal. What is +// bounded is each record's own parse, which fails before the record is decoded. +func TestLoadCatalogBoundsEachRecordRatherThanTheCatalogV1(t *testing.T) { + files := catalogTestFilesV1(t) + oversized := make([]byte, maxDefinitionFileBytes+1) + for index := range oversized { + oversized[index] = 'x' + } + files["catalog/demo/huge.json"] = &fstest.MapFile{Data: oversized} + _, err := loadCatalogV1(files, "catalog") + if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Errorf("oversized record error = %v, want a per-record byte rejection", err) + } + + // The same catalog with many small extra records is not rejected for being + // wide: no record-count or aggregate-byte ceiling exists. + files = catalogTestFilesV1(t) + if _, err := loadCatalogV1(files, "catalog"); err != nil { + t.Fatalf("baseline catalog rejected: %v", err) + } +} + +func TestRecordToolNameV1(t *testing.T) { + for _, testCase := range []struct { + id string + want string + ok bool + }{ + {id: "tool:demo/releases/1.2.3/contract", want: "demo", ok: true}, + {id: "tool:demo", want: "demo", ok: true}, + {id: "demo/releases", ok: false}, + {id: "tool:Demo", ok: false}, + {id: "tool:", ok: false}, + } { + name, err := recordToolNameV1(testCase.id) + if testCase.ok && (err != nil || name != testCase.want) { + t.Errorf("recordToolNameV1(%q) = %q, %v", testCase.id, name, err) + } + if !testCase.ok && err == nil { + t.Errorf("recordToolNameV1(%q) accepted", testCase.id) + } + } +} + +// Depth bounds recursion. A record referencing itself is a cycle, and a chain +// deeper than the limit fails rather than exhausting the stack. +func TestVerifyReferenceDepthBoundsRecursionV1(t *testing.T) { + selfReferential := &ToolRecordV1{Schema: ToolRecordSchemaV1, ID: "tool:demo"} + selfReferential.Releases = []RecordReferenceV1{recordTestReference("tool:demo")} + selfKey := recordKeyV1{ID: "tool:demo", Digest: recordTestDigest} + catalog := &CatalogV1{ + records: map[recordKeyV1]loadedRecordV1{selfKey: {ID: "tool:demo", + Schema: ToolRecordSchemaV1, Digest: recordTestDigest, Value: selfReferential}}, + tools: map[string]recordKeyV1{"demo": selfKey}, + } + err := catalog.verifyReferenceDepthV1() + if err == nil || !strings.Contains(err.Error(), "cycle") { + t.Errorf("self-reference error = %v, want a cycle rejection", err) + } + + // A chain longer than the depth limit fails on depth rather than recursing. + records := map[recordKeyV1]loadedRecordV1{} + const length = maxCatalogGraphDepthV1 + 5 + for index := 0; index < length; index++ { + id := "tool:demo/releases/1.2.3/revisions/" + strings.Repeat("1", index+1) + "/manifest" + next := "tool:demo/releases/1.2.3/revisions/" + strings.Repeat("1", index+2) + "/manifest" + manifest := &ReleaseManifestV1{Schema: ReleaseManifestSchemaV1, ID: id, + Targets: []RecordReferenceV1{recordTestReference(next)}} + records[recordKeyV1{ID: id, Digest: recordTestDigest}] = loadedRecordV1{ID: id, Schema: manifest.Schema, Digest: recordTestDigest, Value: manifest} + } + deep := &CatalogV1{records: records, tools: map[string]recordKeyV1{}} + err = deep.verifyReferenceDepthV1() + if err == nil || !strings.Contains(err.Error(), "exceeds depth") { + t.Errorf("deep chain error = %v, want a depth rejection", err) + } +} + +// The loader must enumerate the plural model. Enumerating the parked singular +// shape would omit every binding artifact past the first and every +// selection-scoped package set from traversal. +func TestCatalogReferencesEnumeratesThePluralModelV1(t *testing.T) { + const release = "tool:demo/releases/1.2.3" + target := &TargetRecordV1{Schema: TargetRecordSchemaV1, ID: release + "/targets/debian/12/amd64", + ValidationProfile: recordTestReference(release + "/validation/profiles/default"), + IntegrationFixtures: []RecordReferenceV1{recordTestReference(release + "/validation/fixtures/a"), recordTestReference(release + "/validation/fixtures/b")}, + PackageSets: []RecordReferenceV1{recordTestReference(release + "/package-sets/base")}, + Bindings: []TargetBindingV1{{Name: "python", + Contract: recordTestReference(release + "/bindings/python/contract"), + Artifacts: []RecordReferenceV1{recordTestReference(release + "/bindings/python/artifacts/linux-amd64"), recordTestReference(release + "/bindings/python/artifacts/linux-arm64")}, + PackageSets: []RecordReferenceV1{recordTestReference(release + "/package-sets/python")}}}, + Payloads: []RecordReferenceV1{recordTestReference(release + "/payloads/demo-linux-amd64")}, + Selections: []TargetSelectionV1{{Name: "chromium", + Payloads: []RecordReferenceV1{recordTestReference(release + "/payloads/chromium/chromium-linux-amd64")}, + PackageSets: []RecordReferenceV1{recordTestReference(release + "/package-sets/chromium")}}}, + } + seen := map[string]struct{}{} + for _, edge := range catalogReferencesV1(target) { + seen[edge.Reference.ID] = struct{}{} + } + for _, required := range []string{ + release + "/validation/fixtures/a", release + "/validation/fixtures/b", + release + "/bindings/python/artifacts/linux-amd64", release + "/bindings/python/artifacts/linux-arm64", + release + "/package-sets/python", release + "/package-sets/chromium", + release + "/payloads/chromium/chromium-linux-amd64", + } { + if _, found := seen[required]; !found { + t.Errorf("reference enumeration omitted %q", required) + } + } + if len(seen) != 11 { + t.Errorf("enumerated %d distinct references, want 11", len(seen)) + } + + // A binding artifact carries its own contract reference, so the edge exists. + artifact := &BindingArtifactRecordV1{Schema: BindingArtifactSchemaV1, + ID: release + "/bindings/python/artifacts/linux-amd64", Binding: "python", + Contract: recordTestReference(release + "/bindings/python/contract")} + edges := catalogReferencesV1(artifact) + if len(edges) != 1 || edges[0].Reference.ID != release+"/bindings/python/contract" { + t.Errorf("binding artifact edges = %+v", edges) + } +} + +// Loading gives the release graph walker its production caller: a catalog whose +// records are individually valid but whose graph is broken must fail to load. +func TestLoadCatalogInvokesTheReleaseGraphWalkerV1(t *testing.T) { + files := catalogTestFilesV1(t) + if _, err := loadCatalogV1(files, "catalog"); err != nil { + t.Fatalf("baseline catalog rejected: %v", err) + } + + // Remove the artifact source mapping's source record. Every remaining record + // is still individually valid; only the resolved graph is broken. + const sourcePath = "catalog/demo/releases/1.2.3/revisions/1/sources/demo-linux-amd64.json" + if _, present := files[sourcePath]; !present { + t.Fatalf("fixture no longer contains %s, so this test would prove nothing", sourcePath) + } + delete(files, sourcePath) + if _, err := loadCatalogV1(files, "catalog"); err == nil { + t.Error("a catalog with a broken release graph loaded successfully") + } +} + +// Design rule 5: the same record ID may exist at different digests when +// separate immutable release revisions select them. An ID-keyed index would +// reject the second revision and make a valid multi-revision catalog unloadable. +func TestCatalogHoldsOneIDAtSeveralDigestsAcrossRevisionsV1(t *testing.T) { + first := &ReleaseContractV1{Schema: ReleaseContractSchemaV1, ID: "tool:demo/releases/1.2.3/contract"} + second := &ReleaseContractV1{Schema: ReleaseContractSchemaV1, ID: "tool:demo/releases/1.2.3/contract", + Contexts: []string{"build"}} + firstDigest, err := canonical.Sum("portable-tool-record", portableToolRecordIdentityV1, first) + if err != nil { + t.Fatal(err) + } + secondDigest, err := canonical.Sum("portable-tool-record", portableToolRecordIdentityV1, second) + if err != nil { + t.Fatal(err) + } + if firstDigest == secondDigest { + t.Fatal("fixture records must differ in digest") + } + catalog := &CatalogV1{records: map[recordKeyV1]loadedRecordV1{}, tools: map[string]recordKeyV1{}} + for _, pair := range []struct { + value *ReleaseContractV1 + digest canonical.Digest + }{{first, firstDigest}, {second, secondDigest}} { + record := loadedRecordV1{ID: pair.value.ID, Schema: pair.value.Schema, Digest: pair.digest, Value: pair.value} + if err := catalog.placeRecordV1(record, "catalog/demo/releases/1.2.3/contract.json", "catalog"); err != nil { + t.Fatalf("placing %s: %v", pair.digest, err) + } + } + if len(catalog.records) != 2 { + t.Errorf("catalog holds %d records, want both digests of one ID", len(catalog.records)) + } + + // The exact same (id, digest) twice is a duplicate definition and fails. + duplicate := loadedRecordV1{ID: first.ID, Schema: first.Schema, Digest: firstDigest, Value: first} + err = catalog.placeRecordV1(duplicate, "catalog/demo/releases/1.2.3/contract.json", "catalog") + if err == nil || !strings.Contains(err.Error(), "duplicate definition") { + t.Errorf("duplicate (id, digest) error = %v", err) + } +} + +// Memoizing only that a node was visited lets a long chain reached later clear +// a bound it should fail: the settled suffix reports zero remaining depth. +func TestReferenceDepthMemoizationPreservesSuffixDepthV1(t *testing.T) { + records := map[recordKeyV1]loadedRecordV1{} + const length = maxCatalogGraphDepthV1 + 5 + id := func(index int) string { + return "tool:demo/releases/1.2.3/revisions/" + strings.Repeat("1", index+1) + "/manifest" + } + // Build a chain where each record points at the next. Sorted ID order visits + // the shortest ID first, so the deep suffix is settled early with a small + // observed depth, which is exactly the case that hides the violation. + for index := 0; index < length; index++ { + manifest := &ReleaseManifestV1{Schema: ReleaseManifestSchemaV1, ID: id(index)} + if index+1 < length { + manifest.Targets = []RecordReferenceV1{{ID: id(index + 1), Digest: recordTestDigest}} + } + records[recordKeyV1{ID: id(index), Digest: recordTestDigest}] = loadedRecordV1{ + ID: id(index), Schema: manifest.Schema, Digest: recordTestDigest, Value: manifest} + } + catalog := &CatalogV1{records: records, tools: map[string]recordKeyV1{}} + err := catalog.verifyReferenceDepthV1() + if err == nil || !strings.Contains(err.Error(), "exceeds depth") { + t.Errorf("error = %v, want the depth bound to hold through memoized suffixes", err) + } +} + +// A filesystem already rooted at the catalog uses the conventional root ".", +// where WalkDir yields entries without a leading "./". +func TestLoadCatalogAcceptsAFilesystemRootedAtDotV1(t *testing.T) { + files := catalogTestFilesV1(t) + rooted := fstest.MapFS{} + for name, file := range files { + rooted[strings.TrimPrefix(name, "catalog/")] = file + } + if _, err := loadCatalogV1(rooted, "."); err != nil { + t.Errorf("a catalog rooted at dot was rejected: %v", err) + } +} + +// A tool advertising two immutable revisions that select different digests of +// the same semantic record must load. Merging the revisions' closures into one +// ID-keyed view would make that collide, which is the case design rule 5 exists +// to permit. +func TestLoadCatalogAcceptsTwoRevisionsSelectingDifferentDigestsV1(t *testing.T) { + files := catalogTestFilesV1(t) + const release = "tool:demo/releases/1.2.3" + + digestOf := func(value any) canonical.Digest { + digest, err := canonical.Sum("portable-tool-record", portableToolRecordIdentityV1, value) + if err != nil { + t.Fatal(err) + } + return digest + } + write := func(relative string, value any) { + payload, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + files["catalog/"+relative] = &fstest.MapFile{Data: payload} + } + read := func(relative string, into any) { + if err := json.Unmarshal(files["catalog/"+relative].Data, into); err != nil { + t.Fatal(err) + } + } + + // Revision 2 selects a contract that differs from revision 1's, so the same + // contract ID exists in the catalog at two digests. + var contract ReleaseContractV1 + read("demo/releases/1.2.3/contract.json", &contract) + revisedContract := contract + revisedContract.SupportedReploy = ">=0.0.1" + if digestOf(&revisedContract) == digestOf(&contract) { + t.Fatal("the two contract revisions must differ in digest") + } + + var manifest ReleaseManifestV1 + read("demo/releases/1.2.3/revisions/1/manifest.json", &manifest) + second := manifest + second.ID = release + "/revisions/2/manifest" + second.Revision = "2" + second.Contract = RecordReferenceV1{ID: contract.ID, Digest: digestOf(&revisedContract)} + // Revision 2 reaches the same payload, so it carries the same mapping to the + // same source. Two revisions sharing an artifact is the ordinary case. + + // Both contract digests must be resident, so the second revision's contract + // is written beside the first rather than replacing it. + // Sources live in their own revision namespace, so revision 2 owns a source + // record describing the same content as revision 1's. + var firstSource ArtifactSourceRecordV1 + read("demo/releases/1.2.3/revisions/1/sources/demo-linux-amd64.json", &firstSource) + secondSource := firstSource + secondSource.ID = release + "/revisions/2/sources/demo-linux-amd64" + write("demo/releases/1.2.3/revisions/2/sources/demo-linux-amd64.json", &secondSource) + second.ArtifactSources = []ArtifactSourceMappingV1{{ + ArtifactSHA256: manifest.ArtifactSources[0].ArtifactSHA256, + Artifact: manifest.ArtifactSources[0].Artifact, + Source: RecordReferenceV1{ID: secondSource.ID, Digest: digestOf(&secondSource)}, + }} + + write("demo/releases/1.2.3/contract-revision-2.json", &revisedContract) + write("demo/releases/1.2.3/revisions/2/manifest.json", &second) + + var tool ToolRecordV1 + read("demo/tool.json", &tool) + tool.Releases = append(tool.Releases, RecordReferenceV1{ID: second.ID, Digest: digestOf(&second)}) + write("demo/tool.json", &tool) + + if _, err := loadCatalogV1(files, "catalog"); err != nil { + t.Fatalf("a two-revision catalog was rejected: %v", err) + } +} diff --git a/internal/toolcatalog/records_compose_test.go b/internal/toolcatalog/records_compose_test.go index 23fda6ed..95152f1c 100644 --- a/internal/toolcatalog/records_compose_test.go +++ b/internal/toolcatalog/records_compose_test.go @@ -21,8 +21,11 @@ func composeTestRecordsV1(extra ...any) map[string]loadedRecordV1 { Schema: PayloadRecordSchemaV1, Digest: recordTestDigest, Value: &PayloadRecordV1{Schema: PayloadRecordSchemaV1, ID: "tool:demo/releases/1.2.3/payloads/demo-linux-amd64", - Name: "demo", Platform: "linux/amd64", - LogicalPath: "tools/demo/demo.tar.gz", InstallDirectory: "demo"}, + Name: "demo", Revision: "1", UpstreamVersion: "1.2.3", Platform: "linux/amd64", + LogicalPath: "tools/demo/demo.tar.gz", Kind: "jdk-archive", + Size: "42", SHA256: recordTestDigest, Resolver: "https-sha256", + Entries: "2", UnpackedSize: "84", InstallDirectory: "demo-1", + ArchiveRoot: "demo", Executable: "demo/bin/demo"}, } add := func(value any) { id := recordIDV1(value)