From 9c8b1def483ad973a54296f0850e4df872735857 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Tue, 18 Aug 2026 05:44:23 +0800 Subject: [PATCH] Validate immutable portable tool records Add record-local validation for every v1 record family: tool version policy and aliases, release manifests and contracts, target identities and records, binding contracts and artifacts, payloads, artifact sources, native package sets, integration fixtures, and validation profiles. Version policy follows each record's declared scheme, artifacts require exact size and digest, and every diagnostic has focused negative coverage. Wire record-local validation into record decoding, which the decoding slice deliberately left unhooked. Move PR 85's release-alias correction here: release aliases must be a bounded array of canonical, unique, sorted values, each different from the exact version. Promote the shared version dependency to direct in go.mod, because this is the first slice to import go-version/pkg/semver directly. Delivers PTD-04 of docs/PORTABLE_TOOL_DEFINITION_IMPLEMENTATION_PLAN.md. --- go.mod | 2 +- internal/toolcatalog/records_decode.go | 6 + internal/toolcatalog/records_validate.go | 1486 +++++++++++++++++ internal/toolcatalog/records_validate_test.go | 956 +++++++++++ 4 files changed, 2449 insertions(+), 1 deletion(-) create mode 100644 internal/toolcatalog/records_validate.go create mode 100644 internal/toolcatalog/records_validate_test.go diff --git a/go.mod b/go.mod index 32994e24..986d7bd8 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/Microsoft/go-winio v0.6.2 github.com/aquasecurity/go-pep440-version v0.0.1 + github.com/aquasecurity/go-version v0.0.1 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 @@ -22,7 +23,6 @@ require ( require ( dario.cat/mergo v1.0.0 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect - github.com/aquasecurity/go-version v0.0.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect diff --git a/internal/toolcatalog/records_decode.go b/internal/toolcatalog/records_decode.go index 6cfc0046..d28130a5 100644 --- a/internal/toolcatalog/records_decode.go +++ b/internal/toolcatalog/records_decode.go @@ -73,6 +73,9 @@ func decodeRecordV1(filename string, payload []byte) (loadedRecordV1, error) { return loadedRecordV1{}, fmt.Errorf("decode %s: %w", filename, err) } record := loadedRecordV1{ID: header.ID, Schema: header.Schema, Path: filename, Value: value} + if err := validateLoadedRecordV1(record); err != nil { + return loadedRecordV1{}, fmt.Errorf("validate %s: %w", filename, err) + } digest, err := canonical.Sum("portable-tool-record", portableToolRecordIdentityV1, value) if err != nil { return loadedRecordV1{}, fmt.Errorf("digest %s: %w", filename, err) @@ -578,6 +581,9 @@ func validateSortedUniqueStringsV1(field string, values []string, allowEmpty boo if values == nil { return fmt.Errorf("%s must use an array", field) } + if len(values) > maxDefinitionReferences { + return fmt.Errorf("%s must use at most %d entries", field, maxDefinitionReferences) + } for index, value := range values { if !allowEmpty && value == "" || strings.TrimSpace(value) != value || containsControlV1(value) || index > 0 && values[index-1] >= value { return fmt.Errorf("%s must contain unique sorted canonical values", field) diff --git a/internal/toolcatalog/records_validate.go b/internal/toolcatalog/records_validate.go new file mode 100644 index 00000000..631d205a --- /dev/null +++ b/internal/toolcatalog/records_validate.go @@ -0,0 +1,1486 @@ +package toolcatalog + +import ( + "bytes" + "fmt" + "path" + "sort" + "strconv" + "strings" + "unicode" + + pep440 "github.com/aquasecurity/go-pep440-version" + "github.com/aquasecurity/go-version/pkg/semver" + dockerreference "github.com/distribution/reference" + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/canonical" + pythonprovider "github.com/omry/reploy/internal/providers/python" +) + +const ( + maxDefinitionValidationCases = 1024 + maxDefinitionArtifactMirrors = 8 +) + +func validateLoadedRecordV1(record loadedRecordV1) error { + if err := validateRecordIDV1(record.ID); err != nil { + return err + } + switch value := record.Value.(type) { + case *ToolRecordV1: + if record.Schema != ToolRecordSchemaV1 || value.Schema != ToolRecordSchemaV1 || value.ID != record.ID || !validRecordIdentifierV1(value.Name) || value.ID != "tool:"+value.Name { + return fmt.Errorf("tool record identity is inconsistent") + } + if err := validateToolVersionPolicyV1(value.VersionScheme, value.DefaultVersion); err != nil { + return err + } + if !validRecordTokenV1(value.Summary) || value.Upstream == "" || value.Source == "" || value.License == "" || value.Documentation == "" || len(value.Releases) == 0 { + return fmt.Errorf("tool metadata and releases must not be empty") + } + for _, raw := range []string{value.Upstream, value.Source, value.Documentation} { + if err := validateSourceURLV1(raw); err != nil { + return fmt.Errorf("tool reference URL: %w", err) + } + } + if !validRecordTokenV1(value.License) { + return fmt.Errorf("tool license is invalid") + } + if err := validateReferenceListV1("tool releases", value.Releases); err != nil { + return err + } + prefix := value.ID + "/releases/" + defaultAdvertised := false + for index, reference := range value.Releases { + segments := strings.Split(reference.ID, "/") + if !strings.HasPrefix(reference.ID, prefix) || len(segments) != 6 || segments[3] != "revisions" || segments[5] != "manifest" { + return fmt.Errorf("tool release reference %d must identify a manifest beneath %q", index, prefix) + } + // The tool record is the only record that knows the version scheme, + // so it is the only one that can reject a release coordinate the + // scheme forbids. The revision rule is the manifest's own. + version, err := decodeToolVersionSegmentV1(segments[2]) + if err != nil { + return fmt.Errorf("tool release reference %d version: %w", index, err) + } + if err := validateToolVersionV1(value.VersionScheme, version); err != nil { + return fmt.Errorf("tool release reference %d: %w", index, err) + } + if err := validateCanonicalDecimalV1(fmt.Sprintf("tool release reference %d revision", index), segments[4], true); err != nil { + return err + } + if version == value.DefaultVersion { + defaultAdvertised = true + } + } + // A versionless opaque request normalizes to equality with the default, + // so a default naming no advertised release makes the tool record + // unsatisfiable. Eligibility beyond advertisement is a graph concern. + if value.VersionScheme == "opaque" && !defaultAdvertised { + return fmt.Errorf("opaque default version %q must name an advertised release", value.DefaultVersion) + } + return nil + case *ReleaseManifestV1: + if record.Schema != ReleaseManifestSchemaV1 || value.Schema != ReleaseManifestSchemaV1 || value.ID != record.ID || !validRecordIdentifierV1(value.Tool) { + return fmt.Errorf("release manifest identity is incomplete") + } + versionSegment, err := encodeToolVersionSegmentV1(value.Version) + if err != nil { + return fmt.Errorf("release manifest version: %w", err) + } + if err := validateCanonicalDecimalV1("release revision", value.Revision, true); err != nil { + return err + } + if err := validateSortedUniqueStringsV1("release aliases", value.Aliases, false); err != nil { + return err + } + for _, alias := range value.Aliases { + if _, err := encodeToolVersionSegmentV1(alias); err != nil { + return fmt.Errorf("release alias %q: %w", alias, err) + } + if alias == value.Version { + return fmt.Errorf("release alias %q redundantly equals its exact version", alias) + } + } + releasePrefix := fmt.Sprintf("tool:%s/releases/%s", value.Tool, versionSegment) + manifestID := fmt.Sprintf("%s/revisions/%s/manifest", releasePrefix, value.Revision) + if value.ID != manifestID { + return fmt.Errorf("release manifest ID must be %q", manifestID) + } + if err := validateRecordReferenceV1(value.Contract); err != nil { + return fmt.Errorf("release contract: %w", err) + } + if value.Contract.ID != releasePrefix+"/contract" { + return fmt.Errorf("release contract reference must identify the current release contract") + } + if err := validateRecordReferenceV1(value.ValidationProfile); err != nil { + return fmt.Errorf("release validation profile: %w", err) + } + if err := validateProfileReferenceV1("release validation profile", value.ValidationProfile, releasePrefix); err != nil { + return err + } + if len(value.Targets) == 0 { + return fmt.Errorf("release manifest targets must not be empty") + } + if err := validateReferenceListV1("release targets", value.Targets); err != nil { + return err + } + for _, reference := range value.Targets { + if err := validateTargetReferenceV1(reference, releasePrefix); err != nil { + return err + } + } + if value.ArtifactSources == nil || len(value.ArtifactSources) > maxDefinitionReferences { + return fmt.Errorf("artifact source mappings must use a bounded array") + } + for index, mapping := range value.ArtifactSources { + if err := mapping.ArtifactSHA256.Validate(); err != nil { + return fmt.Errorf("artifact source mapping %d digest: %w", index, err) + } + if err := validateRecordReferenceV1(mapping.Artifact); err != nil { + return fmt.Errorf("artifact source mapping %d artifact: %w", index, err) + } + if err := validateArtifactSourceTargetV1(mapping.Artifact, releasePrefix); err != nil { + return err + } + if err := validateRecordReferenceV1(mapping.Source); err != nil { + return fmt.Errorf("artifact source mapping %d source: %w", index, err) + } + if err := validateArtifactSourceReferenceV1(mapping.Source, releasePrefix, value.Revision); err != nil { + return err + } + if index > 0 && value.ArtifactSources[index-1].ArtifactSHA256 >= mapping.ArtifactSHA256 { + return fmt.Errorf("artifact source mappings must be unique and sorted by artifact digest") + } + } + if value.Provenance == nil || len(value.Provenance) > maxDefinitionReferences { + return fmt.Errorf("release provenance must use a bounded array") + } + previousProvenance := "" + for index, raw := range value.Provenance { + if err := validateSourceURLV1(raw); err != nil { + return fmt.Errorf("release provenance %d: %w", index, err) + } + if index > 0 && previousProvenance >= raw { + return fmt.Errorf("release provenance must be unique and sorted") + } + previousProvenance = raw + } + return nil + case *ReleaseContractV1: + if record.Schema != ReleaseContractSchemaV1 || value.Schema != ReleaseContractSchemaV1 || value.ID != record.ID { + return fmt.Errorf("release contract identity is inconsistent") + } + if err := validateReleaseContractIDV1(value.ID); err != nil { + return err + } + if err := requireNonemptySortedStringsV1("contract contexts", value.Contexts); err != nil { + return err + } + for _, context := range value.Contexts { + if context != "build" && context != "runtime" { + return fmt.Errorf("contract context %q is unsupported", context) + } + } + if err := validateSupportedReployRequirementV1(value.SupportedReploy); err != nil { + return err + } + if err := requireNonemptySortedStringsV1("resolver primitives", value.ResolverPrimitives); err != nil { + return err + } + for _, primitive := range value.ResolverPrimitives { + if primitive != "https-sha256" { + return fmt.Errorf("resolver primitive %q is unsupported", primitive) + } + } + if err := validateBindingRequestV1(value.Binding); err != nil { + return err + } + if err := validateSelectionRequestV1(value.Selections); err != nil { + return err + } + if err := validateParameterSchemasV1(value.Parameters); err != nil { + return err + } + if err := validateProbeListV1("contract probes", value.Probes, true); err != nil { + return err + } + if err := validateExportsV1("contract exports", value.Exports); err != nil { + return err + } + return validateRuntimeV1(value.Contexts, value.Runtime) + case *TargetRecordV1: + if record.Schema != TargetRecordSchemaV1 || value.Schema != TargetRecordSchemaV1 || value.ID != record.ID { + return fmt.Errorf("target record identity or validation contract is incomplete") + } + if err := validateTargetIdentityV1(value.Target); err != nil { + return err + } + if err := validateTargetRecordIDV1(value.ID, value.Target); err != nil { + return err + } + releasePrefix := strings.Join(strings.Split(value.ID, "/")[:3], "/") + if len(value.IntegrationFixtures) == 0 { + return fmt.Errorf("target integration fixtures must not be empty") + } + if err := validateReferenceListV1("target integration fixtures", value.IntegrationFixtures); err != nil { + return err + } + for _, reference := range value.IntegrationFixtures { + if err := validateFixtureReferenceV1(reference, releasePrefix); err != nil { + return err + } + } + if err := validateRecordReferenceV1(value.ValidationProfile); err != nil { + return fmt.Errorf("target validation profile: %w", err) + } + if err := validateProfileReferenceV1("target validation profile", value.ValidationProfile, releasePrefix); err != nil { + return err + } + if err := validateReferenceListV1("target package sets", value.PackageSets); err != nil { + return err + } + for _, reference := range value.PackageSets { + if err := validatePackageSetReferenceV1("target package set", reference, releasePrefix); err != nil { + return err + } + } + if err := validateReferenceListV1("target payloads", value.Payloads); err != nil { + return err + } + for _, reference := range value.Payloads { + if err := validatePayloadReferenceV1("target payload", reference, releasePrefix); err != nil { + return err + } + } + if value.Bindings == nil || len(value.Bindings) > maxDefinitionReferences { + return fmt.Errorf("target bindings must use a bounded array") + } + for index, binding := range value.Bindings { + if !validRecordIdentifierV1(binding.Name) || index > 0 && value.Bindings[index-1].Name >= binding.Name { + return fmt.Errorf("target bindings must be unique and sorted") + } + if err := validateRecordReferenceV1(binding.Contract); err != nil { + return fmt.Errorf("target binding %q contract: %w", binding.Name, err) + } + if binding.Contract.ID != fmt.Sprintf("%s/bindings/%s/contract", releasePrefix, binding.Name) { + return fmt.Errorf("target binding %q contract must identify its current-release binding contract", binding.Name) + } + if len(binding.Artifacts) == 0 { + return fmt.Errorf("target binding %q artifacts must not be empty", binding.Name) + } + if err := validateReferenceListV1("target binding artifacts", binding.Artifacts); err != nil { + return err + } + for _, reference := range binding.Artifacts { + if err := validateBindingArtifactReferenceV1(reference, releasePrefix, binding.Name); err != nil { + return err + } + } + if err := validateReferenceListV1("target binding package sets", binding.PackageSets); err != nil { + return err + } + for _, reference := range binding.PackageSets { + if err := validatePackageSetReferenceV1("target binding package set", reference, releasePrefix); err != nil { + return err + } + } + if err := validateExportsV1("target binding exports", binding.Exports); err != nil { + return err + } + if err := validateProbeListV1("target binding probes", binding.Probes, true); err != nil { + return err + } + } + if value.Selections == nil || len(value.Selections) > maxDefinitionReferences { + return fmt.Errorf("target selections must use a bounded array") + } + for index, selection := range value.Selections { + if !validRecordIdentifierV1(selection.Name) || index > 0 && value.Selections[index-1].Name >= selection.Name { + return fmt.Errorf("target selections must be unique, sorted, and nonempty") + } + if err := validateReferenceListV1("target selection payloads", selection.Payloads); err != nil { + return err + } + for _, reference := range selection.Payloads { + if err := validatePayloadReferenceV1("target selection payload", reference, releasePrefix); err != nil { + return err + } + } + if err := validateReferenceListV1("target selection package sets", selection.PackageSets); err != nil { + return err + } + for _, reference := range selection.PackageSets { + if err := validatePackageSetReferenceV1("target selection package set", reference, releasePrefix); err != nil { + return err + } + } + if err := validateExportsV1("target selection exports", selection.Exports); err != nil { + return err + } + if err := validateProbeListV1("target selection probes", selection.Probes, true); err != nil { + return err + } + if len(selection.Payloads)+len(selection.PackageSets)+len(selection.Exports)+len(selection.Probes) == 0 { + return fmt.Errorf("target selection %q must contribute at least one record, export, or probe", selection.Name) + } + } + if err := validateTargetParameterConstraintsV1(value.Parameters); err != nil { + return err + } + if err := validateExportsV1("target exports", value.Exports); err != nil { + return err + } + return validateProbeListV1("target probes", value.Probes, true) + case *BindingContractV1: + if record.Schema != BindingContractSchemaV1 || value.Schema != BindingContractSchemaV1 || value.ID != record.ID || !validRecordIdentifierV1(value.Name) || !validPackageNameV1(value.Package) || value.CLI == "" { + return fmt.Errorf("binding contract is incomplete") + } + if err := validateBindingContractIDV1(value.ID, value.Name); err != nil { + return err + } + if err := validateAbsoluteRecordPathV1(value.CLI); err != nil { + return fmt.Errorf("binding CLI: %w", err) + } + if err := requireNonemptySortedStringsV1("binding requirements", value.Requirements); err != nil { + return err + } + distributions := make(map[string]string, len(value.Requirements)) + for _, requirement := range value.Requirements { + distribution, err := pythonprovider.PackageRootDistributionNameV1(requirement) + if err != nil { + return fmt.Errorf("binding requirement %q: %w", requirement, err) + } + if previous, found := distributions[distribution]; found { + return fmt.Errorf("binding requirements %q and %q name the same distribution %q", previous, requirement, distribution) + } + distributions[distribution] = requirement + } + if err := requireNonemptySortedStringsV1("supported Python", value.SupportedPython); err != nil { + return err + } + if value.BundledComponents == nil || len(value.BundledComponents) > maxDefinitionReferences { + return fmt.Errorf("binding contract bundled components must use a bounded array") + } + for index, component := range value.BundledComponents { + if !validRecordIdentifierV1(component.Name) || !validRecordSegmentV1(component.Version) || validateRecordPathV1(component.Path, false) != nil { + return fmt.Errorf("binding contract bundled component %d is not canonical", index) + } + if index > 0 && value.BundledComponents[index-1].Name >= component.Name { + return fmt.Errorf("binding contract bundled components must be unique and sorted by name") + } + } + for _, version := range value.SupportedPython { + if err := pythonprovider.ValidateInterpreterVersionV1(version); err != nil { + return fmt.Errorf("supported Python version %q: %w", version, err) + } + } + if err := requireNonemptySortedStringsV1("binding supported tags", value.SupportedTags); err != nil { + return err + } + for _, tag := range value.SupportedTags { + segments := strings.Split(tag, "-") + if len(segments) != 3 || !validWheelTagGroupV1(segments[0]) || !validWheelTagGroupV1(segments[1]) || !validWheelTagGroupV1(segments[2]) { + return fmt.Errorf("binding supported tag %q must be a canonical three-part wheel tag", tag) + } + } + return nil + case *BindingArtifactRecordV1: + if record.Schema != BindingArtifactSchemaV1 || value.Schema != BindingArtifactSchemaV1 || value.ID != record.ID || !validRecordIdentifierV1(value.Binding) || !validPlatformV1(value.Platform) || validateRecordPathV1(value.Filename, false) != nil || path.Dir(value.Filename) != "." { + return fmt.Errorf("binding artifact identity is incomplete") + } + if err := validateBindingArtifactIDV1(value.ID, value.Binding, value.Platform); err != nil { + return err + } + if value.Resolver != "https-sha256" { + return fmt.Errorf("binding artifact resolver %q is unsupported", value.Resolver) + } + if !validRecordIdentifierV1(value.Name) || !validRecordSegmentV1(value.EcosystemVersion) { + return fmt.Errorf("binding artifact component name and ecosystem version must be canonical") + } + if err := validateRecordReferenceV1(value.Contract); err != nil { + return fmt.Errorf("binding artifact contract: %w", err) + } + artifactSegments := strings.Split(value.ID, "/") + expectedContract := strings.Join(artifactSegments[:5], "/") + "/contract" + if value.Contract.ID != expectedContract { + return fmt.Errorf("binding artifact contract reference must be %q", expectedContract) + } + if err := validateBindingArtifactCompatibilityV1(value); err != nil { + return err + } + filenameParts := strings.Split(strings.TrimSuffix(value.Filename, ".whl"), "-") + if len(filenameParts) < 2 || filenameParts[0] != strings.ReplaceAll(pythonprovider.NormalizeDistributionName(value.Name), "-", "_") || filenameParts[1] != value.EcosystemVersion { + return fmt.Errorf("binding artifact name and ecosystem version must match the wheel filename %q", value.Filename) + } + if err := validateCanonicalDecimalV1("binding artifact size", value.Size, true); err != nil { + return err + } + if err := value.SHA256.Validate(); err != nil { + return fmt.Errorf("binding artifact digest: %w", err) + } + if value.BundledComponents == nil || len(value.BundledComponents) > maxDefinitionReferences { + return fmt.Errorf("binding artifact bundled components must use a bounded array") + } + for index, component := range value.BundledComponents { + if !validRecordIdentifierV1(component.Name) || !validRecordSegmentV1(component.Version) || validateRecordPathV1(component.Path, false) != nil || index > 0 && value.BundledComponents[index-1].Name >= component.Name { + return fmt.Errorf("binding artifact bundled components must be complete, unique, and sorted") + } + } + return nil + case *PayloadRecordV1: + if record.Schema != PayloadRecordSchemaV1 || value.Schema != PayloadRecordSchemaV1 || value.ID != record.ID || !validRecordIdentifierV1(value.Name) || !validRecordSegmentV1(value.Revision) || !validRecordSegmentV1(value.UpstreamVersion) || !validPlatformV1(value.Platform) || !supportedPayloadKindV1(value.Kind) { + return fmt.Errorf("payload identity is incomplete") + } + if value.Selection != "" && !validRecordIdentifierV1(value.Selection) { + return fmt.Errorf("payload selection is invalid") + } + if err := validatePayloadIDV1(value); err != nil { + return err + } + if value.Resolver != "https-sha256" { + return fmt.Errorf("payload resolver %q is unsupported", value.Resolver) + } + if err := validateRecordPathV1(value.LogicalPath, false); err != nil { + return fmt.Errorf("payload logical path: %w", err) + } + if err := validateCanonicalDecimalV1("payload size", value.Size, true); err != nil { + return err + } + if err := validateCanonicalDecimalV1("payload entries", value.Entries, true); err != nil { + return err + } + if err := validateCanonicalDecimalV1("payload unpacked size", value.UnpackedSize, true); err != nil { + return err + } + if err := value.SHA256.Validate(); err != nil { + return fmt.Errorf("payload digest: %w", err) + } + if err := validateRecordPathV1(value.InstallDirectory, false); err != nil { + return fmt.Errorf("payload install directory: %w", err) + } + if err := validateRecordPathV1(value.ArchiveRoot, true); err != nil { + return fmt.Errorf("payload archive root: %w", err) + } + if err := validateRecordPathV1(value.Executable, false); err != nil { + return fmt.Errorf("payload executable: %w", err) + } + if path.Dir(value.InstallDirectory) != "." || value.ArchiveRoot != "." && value.Executable != value.ArchiveRoot && !strings.HasPrefix(value.Executable, value.ArchiveRoot+"/") { + return fmt.Errorf("payload paths are inconsistent") + } + if value.Kind == "raw-executable" && (value.Entries != "1" || value.UnpackedSize != value.Size || value.ArchiveRoot != ".") { + return fmt.Errorf("raw executable payload inventory is inconsistent") + } + return nil + case *ArtifactSourceRecordV1: + if record.Schema != ArtifactSourceRecordSchemaV1 || value.Schema != ArtifactSourceRecordSchemaV1 || value.ID != record.ID || value.Resolver != "https-sha256" { + return fmt.Errorf("artifact source identity or resolver is unsupported") + } + if err := validateArtifactSourceIDV1(value.ID); err != nil { + return err + } + if err := value.SHA256.Validate(); err != nil { + return fmt.Errorf("artifact source digest: %w", err) + } + if err := validateCanonicalDecimalV1("artifact source size", value.Size, true); err != nil { + return err + } + if len(value.Mirrors) == 0 || len(value.Mirrors) > maxDefinitionArtifactMirrors { + return fmt.Errorf("artifact source mirrors must contain between 1 and %d entries", maxDefinitionArtifactMirrors) + } + seenMirrors := make(map[string]struct{}, len(value.Mirrors)) + for index, mirror := range value.Mirrors { + if err := validateSourceURLV1(mirror); err != nil { + return fmt.Errorf("artifact source mirror %d: %w", index, err) + } + if _, exists := seenMirrors[mirror]; exists { + return fmt.Errorf("artifact source mirrors must be unique") + } + seenMirrors[mirror] = struct{}{} + } + if len(value.Provenance) == 0 || len(value.Provenance) > maxDefinitionReferences { + return fmt.Errorf("artifact source provenance must use a nonempty bounded array") + } + previousProvenance := "" + for index, provenance := range value.Provenance { + if err := validateSourceURLV1(provenance); err != nil { + return fmt.Errorf("artifact source provenance %d: %w", index, err) + } + if index > 0 && previousProvenance >= provenance { + return fmt.Errorf("artifact source provenance must be unique and sorted") + } + previousProvenance = provenance + } + if err := validateSortedUniqueStringsV1("artifact source diagnostics", value.Diagnostics, false); err != nil { + return err + } + + return nil + case *NativePackageSetV1: + if record.Schema != NativePackageSetSchemaV1 || value.Schema != NativePackageSetSchemaV1 || value.ID != record.ID || value.Manager != "apt" { + return fmt.Errorf("native package-set identity is incomplete") + } + if err := validateNativePackageSetIDV1(value.ID); err != nil { + return err + } + if err := requireNonemptySortedStringsV1("native package requirements", value.Requirements); err != nil { + return err + } + if err := validateSortedUniqueStringsV1("native package repositories", value.Repositories, false); err != nil { + return err + } + if err := validateSortedUniqueStringsV1("native package validation metadata", value.ValidationMetadata, false); err != nil { + return err + } + packages := make(map[string]string, len(value.Requirements)) + for _, requirement := range value.Requirements { + parsed, err := blueprint.ParseAPTPackageRequest(requirement) + if err != nil { + return fmt.Errorf("native package requirement %q: %w", requirement, err) + } + if previous, found := packages[parsed.Name]; found { + return fmt.Errorf("native package requirements %q and %q name the same package %q", previous, requirement, parsed.Name) + } + packages[parsed.Name] = requirement + } + return nil + case *IntegrationFixtureRecordV1: + if record.Schema != IntegrationFixtureSchemaV1 || value.Schema != IntegrationFixtureSchemaV1 || value.ID != record.ID || !validRecordIdentifierV1(value.Name) { + return fmt.Errorf("integration fixture identity is inconsistent") + } + if err := validateTargetIdentityV1(value.Target); err != nil { + return fmt.Errorf("integration fixture target: %w", err) + } + segments := strings.Split(value.ID, "/") + if len(segments) != 6 || segments[1] != "releases" || segments[3] != "validation" || segments[4] != "fixtures" || segments[5] != value.Name { + return fmt.Errorf("integration fixture ID must use its name in a release validation fixture namespace") + } + if _, err := decodeToolVersionSegmentV1(segments[2]); err != nil { + return fmt.Errorf("integration fixture ID version: %w", err) + } + if !validBaseImageReferenceV1(value.BaseImage) { + return fmt.Errorf("integration fixture base image must be a canonical tagged OCI reference") + } + if err := value.BaseImageDigest.Validate(); err != nil { + return fmt.Errorf("integration fixture base image digest: %w", err) + } + if value.Context != "build" && value.Context != "runtime" { + return fmt.Errorf("integration fixture context is unsupported") + } + if value.Binding != "" && !validRecordIdentifierV1(value.Binding) { + return fmt.Errorf("integration fixture binding is invalid") + } + if err := validateSortedUniqueStringsV1("integration fixture selections", value.Selections, false); err != nil { + return err + } + for _, selection := range value.Selections { + if !validRecordIdentifierV1(selection) { + return fmt.Errorf("integration fixture selections must be canonical identifiers") + } + } + return validateParameterValuesV1("integration fixture parameters", value.Parameters) + case *ValidationProfileRecordV1: + if record.Schema != ValidationProfileSchemaV1 || value.Schema != ValidationProfileSchemaV1 || value.ID != record.ID || !validRecordIdentifierV1(value.Tool) { + return fmt.Errorf("validation profile identity is inconsistent") + } + versionSegment, err := encodeToolVersionSegmentV1(value.Version) + if err != nil { + return fmt.Errorf("validation profile version: %w", err) + } + expectedID := fmt.Sprintf("tool:%s/releases/%s/validation/profiles/default", value.Tool, versionSegment) + if value.ID != expectedID { + return fmt.Errorf("validation profile ID must be %q", expectedID) + } + if value.Validator != "java-jdk" && value.Validator != "playwright-python-browser" { + return fmt.Errorf("validation profile validator is unsupported") + } + if _, err := encodeToolVersionSegmentV1(value.ValidatorVersion); err != nil { + return fmt.Errorf("validation profile validator version: %w", err) + } + if err := validateProbeListV1("validation profile probes", value.Probes, false); err != nil { + return err + } + if value.Network != "none" { + return fmt.Errorf("validation profile must disable networking") + } + return nil + default: + return fmt.Errorf("unsupported record value %T", record.Value) + } +} + +// Artifact source mappings carry the size and digest of a concrete downloadable +// file, so they may only name records that own one: release payloads and +// binding artifacts. Both ID shapes are matched in full against the grammar +// their owning records enforce, because a mapping that names a structurally +// impossible ID can never be satisfied by any record in the release. +func validateArtifactSourceTargetV1(reference RecordReferenceV1, releasePrefix string) error { + segments := strings.Split(reference.ID, "/") + if len(segments) >= 5 && strings.Join(segments[:3], "/") == releasePrefix { + switch { + case len(segments) == 5 && segments[3] == "payloads" && validPayloadLeafV1(segments[4]): + return nil + case len(segments) == 6 && segments[3] == "payloads" && + validRecordIdentifierV1(segments[4]) && validPayloadLeafV1(segments[5]): + return nil + case len(segments) == 7 && segments[3] == "bindings" && validRecordIdentifierV1(segments[4]) && + segments[5] == "artifacts" && validPlatformLeafV1(segments[6]): + return nil + } + } + return fmt.Errorf("artifact source mapping artifact %q must reference a payload or binding artifact record inside namespace %q", reference.ID, releasePrefix) +} + +// Payload IDs end in the leaf validatePayloadIDV1 builds: the payload name +// followed by its platform. The name is not knowable from the manifest, so only +// its shape is checked here. +func validPayloadLeafV1(value string) bool { + platform := strings.LastIndex(value, "-") + if platform < 0 { + return false + } + name := strings.LastIndex(value[:platform], "-") + if name < 0 { + return false + } + return validRecordIdentifierV1(value[:name]) && validPlatformLeafV1(value[name+1:]) +} + +// Payload and binding artifact IDs spell a platform with a dash where the +// platform value itself uses a slash. +func validPlatformLeafV1(value string) bool { + return validPlatformV1(strings.ReplaceAll(value, "-", "/")) +} + +// A source mapping must name a record an artifact source could own, so the whole +// ID shape is checked here rather than its namespace prefix alone. The shape is +// the one validateArtifactSourceIDV1 enforces on the owning record. +func validateArtifactSourceReferenceV1(reference RecordReferenceV1, releasePrefix string, revision string) error { + segments := strings.Split(reference.ID, "/") + if len(segments) != 7 || strings.Join(segments[:3], "/") != releasePrefix || segments[3] != "revisions" || + segments[4] != revision || segments[5] != "sources" || !validRecordIdentifierV1(segments[6]) { + return fmt.Errorf("artifact source mapping source %q must name an artifact source record in revision %q", reference.ID, revision) + } + return nil +} + +// Cross-record references must name an ID the owning record could actually hold. +// A namespace prefix alone admits IDs no record can own, so each reference below +// is checked against the same shape its owning record's ID validator enforces. + +func referenceSegmentsUnderV1(reference RecordReferenceV1, releasePrefix string, count int) ([]string, bool) { + segments := strings.Split(reference.ID, "/") + if len(segments) != count || strings.Join(segments[:3], "/") != releasePrefix { + return nil, false + } + return segments, true +} + +// Mirrors validateTargetRecordIDV1. +func validateTargetReferenceV1(reference RecordReferenceV1, releasePrefix string) error { + segments, ok := referenceSegmentsUnderV1(reference, releasePrefix, 7) + if !ok || segments[3] != "targets" || !validRecordIdentifierV1(segments[4]) || + !validRecordSegmentV1(segments[5]) || !supportedArchitectureV1(segments[6]) { + return fmt.Errorf("release target %q must name a target record under %q", reference.ID, releasePrefix+"/targets") + } + return nil +} + +// Mirrors validateNativePackageSetIDV1. +func validatePackageSetReferenceV1(field string, reference RecordReferenceV1, releasePrefix string) error { + segments, ok := referenceSegmentsUnderV1(reference, releasePrefix, 5) + if !ok || segments[3] != "package-sets" || !validRecordIdentifierV1(segments[4]) { + return fmt.Errorf("%s %q must name a native package-set record under %q", field, reference.ID, releasePrefix+"/package-sets") + } + return nil +} + +// Mirrors validatePayloadIDV1, which admits an unconditional and a selected form. +func validatePayloadReferenceV1(field string, reference RecordReferenceV1, releasePrefix string) error { + segments := strings.Split(reference.ID, "/") + unconditional := len(segments) == 5 && validPayloadLeafV1(segments[4]) + selected := len(segments) == 6 && validRecordIdentifierV1(segments[4]) && validPayloadLeafV1(segments[5]) + if len(segments) < 5 || strings.Join(segments[:3], "/") != releasePrefix || segments[3] != "payloads" || + !unconditional && !selected { + return fmt.Errorf("%s %q must name a payload record under %q", field, reference.ID, releasePrefix+"/payloads") + } + return nil +} + +// Mirrors validateBindingArtifactIDV1 for the binding that advertises it. +func validateBindingArtifactReferenceV1(reference RecordReferenceV1, releasePrefix string, binding string) error { + segments, ok := referenceSegmentsUnderV1(reference, releasePrefix, 7) + if !ok || segments[3] != "bindings" || segments[4] != binding || segments[5] != "artifacts" || + !validPlatformLeafV1(segments[6]) { + return fmt.Errorf("target binding artifact %q must name an artifact of binding %q", reference.ID, binding) + } + return nil +} + +// Mirrors the integration fixture ID rule: the fixture name is its leaf. +func validateFixtureReferenceV1(reference RecordReferenceV1, releasePrefix string) error { + segments, ok := referenceSegmentsUnderV1(reference, releasePrefix, 6) + if !ok || segments[3] != "validation" || segments[4] != "fixtures" || !validRecordIdentifierV1(segments[5]) { + return fmt.Errorf("target integration fixture %q must name a fixture record under %q", reference.ID, releasePrefix+"/validation/fixtures") + } + return nil +} + +// Mirrors the validation profile ID rule, whose only published leaf is "default". +func validateProfileReferenceV1(field string, reference RecordReferenceV1, releasePrefix string) error { + if reference.ID != releasePrefix+"/validation/profiles/default" { + return fmt.Errorf("%s %q must be %q", field, reference.ID, releasePrefix+"/validation/profiles/default") + } + return nil +} + +func validateToolVersionPolicyV1(scheme string, defaultVersion string) error { + switch scheme { + case "semver", "pep440", "integer": + if defaultVersion != "" { + return fmt.Errorf("ordered tool version schemes must not declare a default version") + } + case "opaque": + if _, err := encodeToolVersionSegmentV1(defaultVersion); err != nil { + return fmt.Errorf("opaque tool version scheme requires a canonical default version") + } + default: + return fmt.Errorf("tool version scheme is unsupported") + } + return nil +} + +func validateToolVersionAliasV1(scheme string, value string) error { + switch scheme { + case "semver": + if _, err := semver.Parse(value); err != nil { + parts := strings.Split(value, ".") + if len(parts) == 0 || len(parts) > 2 { + return fmt.Errorf("alias %q is invalid under SemVer", value) + } + for _, part := range parts { + if err := validateCanonicalDecimalV1("SemVer alias component", part, false); err != nil { + return fmt.Errorf("alias %q is invalid under SemVer", value) + } + } + } + case "pep440": + if _, err := pep440.Parse(value); err != nil { + return fmt.Errorf("alias %q is invalid under PEP 440", value) + } + case "integer": + if err := validateCanonicalDecimalV1("integer tool version alias", value, false); err != nil { + return err + } + case "opaque": + if _, err := encodeToolVersionSegmentV1(value); err != nil { + return err + } + default: + return fmt.Errorf("tool version scheme is unsupported") + } + return nil +} + +func validateToolVersionV1(scheme string, value string) error { + switch scheme { + case "semver": + parsed, err := semver.Parse(value) + if err != nil || parsed.String() != value { + return fmt.Errorf("version %q is not canonical SemVer", value) + } + case "pep440": + parsed, err := pep440.Parse(value) + if err != nil || parsed.String() != value { + return fmt.Errorf("version %q is not canonical PEP 440", value) + } + case "integer": + if err := validateCanonicalDecimalV1("integer tool version", value, false); err != nil { + return err + } + case "opaque": + if _, err := encodeToolVersionSegmentV1(value); err != nil { + return err + } + default: + return fmt.Errorf("tool version scheme is unsupported") + } + return nil +} + +func validateSupportedReployRequirementV1(requirement string) error { + if !validRecordTokenV1(requirement) { + return fmt.Errorf("supported Reploy requirement is invalid") + } + if strings.IndexFunc(requirement, unicode.IsSpace) >= 0 { + return fmt.Errorf("supported Reploy requirement is not canonical SemVer") + } + constraints, err := semver.NewConstraints(requirement) + if err != nil || constraints.String() != requirement { + return fmt.Errorf("supported Reploy requirement is not canonical SemVer") + } + return nil +} + +func validatePayloadIDV1(value *PayloadRecordV1) error { + segments := strings.Split(value.ID, "/") + if len(segments) < 5 || segments[1] != "releases" || segments[3] != "payloads" { + return fmt.Errorf("payload ID must use a release payload namespace") + } + if _, err := decodeToolVersionSegmentV1(segments[2]); err != nil { + return fmt.Errorf("payload ID version: %w", err) + } + expectedLeaf := value.Name + "-" + strings.ReplaceAll(value.Platform, "/", "-") + if value.Selection == "" { + if len(segments) != 5 || segments[4] != expectedLeaf { + return fmt.Errorf("unconditional payload ID must end with /payloads/%s", expectedLeaf) + } + return nil + } + if len(segments) != 6 || segments[4] != value.Selection || segments[5] != expectedLeaf { + return fmt.Errorf("selected payload ID must end with /payloads/%s/%s", value.Selection, expectedLeaf) + } + return nil +} + +func validBaseImageReferenceV1(value string) bool { + if !validRecordTokenV1(value) || strings.ToLower(value) != value || strings.ContainsAny(value, "@?#") || strings.Contains(value, "://") { + return false + } + named, err := dockerreference.ParseNormalizedNamed(value) + if err != nil || named.String() != value { + return false + } + _, tagged := named.(dockerreference.NamedTagged) + _, digested := named.(dockerreference.Canonical) + return tagged && !digested +} + +func validateProbeV1(probe RecordProbeV1) error { + if validateAbsoluteRecordPathV1(probe.Path) != nil || probe.Args == nil || len(probe.Args) > maxDefinitionReferences || probe.Network != "none" { + return fmt.Errorf("probe must use an absolute path, argument array, and network=none") + } + for _, argument := range probe.Args { + if containsControlV1(argument) { + return fmt.Errorf("probe arguments must not contain control characters") + } + } + return nil +} + +func validateProbeListV1(field string, probes []RecordProbeV1, allowEmpty bool) error { + if probes == nil || len(probes) > maxDefinitionReferences || !allowEmpty && len(probes) == 0 { + if allowEmpty { + return fmt.Errorf("%s must use a bounded array", field) + } + return fmt.Errorf("%s must use a nonempty bounded array", field) + } + var previous []byte + for index, probe := range probes { + if err := validateProbeV1(probe); err != nil { + return fmt.Errorf("%s[%d]: %w", field, index, err) + } + key, err := canonical.Marshal(probe) + if err != nil { + return fmt.Errorf("%s[%d] canonical form: %w", field, index, err) + } + if index > 0 && bytes.Compare(previous, key) >= 0 { + return fmt.Errorf("%s must be unique and sorted", field) + } + previous = key + } + return nil +} + +func validateExportsV1(field string, exports []ToolExportV1) error { + if exports == nil || len(exports) > maxDefinitionReferences { + return fmt.Errorf("%s must use a bounded array", field) + } + for index, exported := range exports { + if !validRecordIdentifierV1(exported.Name) || validateAbsoluteRecordPathV1(exported.Path) != nil || index > 0 && exports[index-1].Name >= exported.Name { + return fmt.Errorf("%s must be unique, sorted, and absolute", field) + } + } + return nil +} + +func validateRuntimeV1(contexts []string, runtime *RecordRuntimeV1) error { + hasRuntime := containsRecordValueV1(contexts, "runtime") + if runtime == nil { + if hasRuntime { + return fmt.Errorf("runtime context requires a runtime contract") + } + return nil + } + if !hasRuntime || validateAbsoluteRecordPathV1(runtime.InstallRoot) != nil { + return fmt.Errorf("runtime contract is inconsistent with contexts") + } + if runtime.Environment == nil || len(runtime.Environment) > maxDefinitionReferences { + return fmt.Errorf("runtime environment must use a bounded array") + } + for index, variable := range runtime.Environment { + if !validEnvironmentNameV1(variable.Name) || containsControlV1(variable.Value) || index > 0 && runtime.Environment[index-1].Name >= variable.Name { + return fmt.Errorf("runtime environment variables must be unique and sorted") + } + } + return nil +} + +func validateBindingRequestV1(binding BindingRequestV1) error { + if err := validateSortedUniqueStringsV1("binding options", binding.Options, false); err != nil { + return err + } + if binding.Required && len(binding.Options) == 0 { + return fmt.Errorf("required binding must declare at least one option") + } + for _, option := range binding.Options { + if !validRecordIdentifierV1(option) { + return fmt.Errorf("binding options must be canonical identifiers") + } + } + if binding.Default != "" && !containsRecordValueV1(binding.Options, binding.Default) { + return fmt.Errorf("default binding must be one of the declared options") + } + return nil +} + +func validateSelectionRequestV1(selections SelectionRequestV1) error { + if err := validateSortedUniqueStringsV1("selection options", selections.Options, false); err != nil { + return err + } + if err := validateCanonicalDecimalV1("minimum selections", selections.Minimum, false); err != nil { + return err + } + if err := validateCanonicalDecimalV1("maximum selections", selections.Maximum, false); err != nil { + return err + } + for _, option := range selections.Options { + if !validRecordIdentifierV1(option) { + return fmt.Errorf("selection options must be canonical identifiers") + } + } + minimum, _ := strconv.ParseUint(selections.Minimum, 10, 63) + maximum, _ := strconv.ParseUint(selections.Maximum, 10, 63) + if minimum > maximum || maximum > uint64(len(selections.Options)) { + return fmt.Errorf("selection cardinality is inconsistent with the declared options") + } + if err := validateSelectionCompatibilityGroupsV1(selections.Options, selections.CompatibilityGroups); err != nil { + return err + } + if minimum > 0 { + feasible := false + for _, group := range selections.CompatibilityGroups { + feasible = feasible || uint64(len(group)) >= minimum + } + if !feasible { + return fmt.Errorf("minimum selections cannot be satisfied by any compatibility group") + } + } + if err := validateSortedUniqueStringsV1("default selections", selections.Defaults, false); err != nil { + return err + } + for _, selection := range selections.Defaults { + if !containsRecordValueV1(selections.Options, selection) { + return fmt.Errorf("default selection %q is not a declared option", selection) + } + } + if len(selections.Defaults) != 0 && (uint64(len(selections.Defaults)) < minimum || uint64(len(selections.Defaults)) > maximum || !selectionSetCompatibleV1(selections.Defaults, selections.CompatibilityGroups)) { + return fmt.Errorf("default selections do not satisfy the selection contract") + } + return nil +} + +func validateSelectionCompatibilityGroupsV1(options []string, groups [][]string) error { + if groups == nil || len(groups) > maxDefinitionReferences { + return fmt.Errorf("selection compatibility groups must use a bounded array") + } + covered := make(map[string]bool, len(options)) + totalOptions := 0 + for index, group := range groups { + if len(group) == 0 || len(group) > maxDefinitionReferences { + return fmt.Errorf("selection compatibility groups must be nonempty and bounded") + } + totalOptions += len(group) + if totalOptions > maxDefinitionReferences { + return fmt.Errorf("selection compatibility groups exceed the total option limit") + } + if err := validateSortedUniqueStringsV1("selection compatibility group", group, false); err != nil { + return err + } + for _, option := range group { + if !containsRecordValueV1(options, option) { + return fmt.Errorf("selection compatibility group contains undeclared option %q", option) + } + covered[option] = true + } + if index > 0 && compareRecordStringSlicesV1(groups[index-1], group) >= 0 { + return fmt.Errorf("selection compatibility groups must be unique and sorted") + } + } + for _, option := range options { + if !covered[option] { + return fmt.Errorf("selection compatibility groups do not cover option %q", option) + } + } + for left := range groups { + for right := range groups { + if left != right && recordStringSliceSubsetV1(groups[left], groups[right]) { + return fmt.Errorf("selection compatibility groups must be maximal") + } + } + } + return nil +} + +func validateParameterSchemasV1(parameters []ParameterSchemaV1) error { + if parameters == nil || len(parameters) > maxDefinitionReferences { + return fmt.Errorf("contract parameters must use a bounded array") + } + coverage := uint64(1) + for index, parameter := range parameters { + if !validRecordIdentifierV1(parameter.Name) || index > 0 && parameters[index-1].Name >= parameter.Name { + return fmt.Errorf("contract parameters must have unique sorted canonical names") + } + if parameter.Values == nil { + return fmt.Errorf("parameter %q values must use an array", parameter.Name) + } + var domainSize uint64 + switch parameter.Type { + case "boolean": + if len(parameter.Values) != 0 || parameter.Minimum != "" || parameter.Maximum != "" { + return fmt.Errorf("boolean parameter %q must not declare enum or range constraints", parameter.Name) + } + domainSize = 2 + case "enum": + if parameter.Minimum != "" || parameter.Maximum != "" { + return fmt.Errorf("enum parameter %q must not declare range constraints", parameter.Name) + } + if err := requireNonemptySortedStringsV1("enum parameter values", parameter.Values); err != nil { + return err + } + domainSize = uint64(len(parameter.Values)) + case "integer": + if len(parameter.Values) != 0 { + return fmt.Errorf("integer parameter %q must not declare enum values", parameter.Name) + } + minimum, err := parseCanonicalIntegerV1("integer parameter minimum", parameter.Minimum) + if err != nil { + return err + } + maximum, err := parseCanonicalIntegerV1("integer parameter maximum", parameter.Maximum) + if err != nil { + return err + } + if minimum > maximum { + return fmt.Errorf("integer parameter %q range is inverted", parameter.Name) + } + if !boundedParameterRangeV1(minimum, maximum) { + return fmt.Errorf("integer parameter %q range exceeds the enumerable domain limit", parameter.Name) + } + domainSize = uint64(maximum-minimum) + 1 + default: + return fmt.Errorf("parameter %q type is unsupported", parameter.Name) + } + if parameter.Default != nil && !parameterValueInSchemaV1(*parameter.Default, parameter) { + return fmt.Errorf("parameter %q default is outside its declared domain", parameter.Name) + } + if !parameter.Required && parameter.Default == nil { + domainSize++ + } + if domainSize == 0 || coverage > uint64(maxDefinitionValidationCases)/domainSize { + return fmt.Errorf("contract parameter Cartesian coverage exceeds the validation case limit") + } + coverage *= domainSize + } + return nil +} + +func parameterValueInSchemaV1(value string, parameter ParameterSchemaV1) bool { + switch parameter.Type { + case "boolean": + return value == "false" || value == "true" + case "enum": + return containsRecordValueV1(parameter.Values, value) + case "integer": + parsed, err := parseCanonicalIntegerV1("parameter value", value) + minimum, minimumErr := parseCanonicalIntegerV1("parameter minimum", parameter.Minimum) + maximum, maximumErr := parseCanonicalIntegerV1("parameter maximum", parameter.Maximum) + return err == nil && minimumErr == nil && maximumErr == nil && parsed >= minimum && parsed <= maximum + default: + return false + } +} + +func validateTargetParameterConstraintsV1(parameters []TargetParameterConstraintV1) error { + if parameters == nil || len(parameters) > maxDefinitionReferences { + return fmt.Errorf("target parameter constraints must use a bounded array") + } + for index, parameter := range parameters { + if !validRecordIdentifierV1(parameter.Name) || index > 0 && parameters[index-1].Name >= parameter.Name { + return fmt.Errorf("target parameter constraints must have unique sorted canonical names") + } + if parameter.Values == nil { + return fmt.Errorf("target parameter constraint %q values must use an array", parameter.Name) + } + if len(parameter.Values) != 0 { + if parameter.Minimum != "" || parameter.Maximum != "" { + return fmt.Errorf("target parameter constraint %q cannot mix values and a range", parameter.Name) + } + if err := requireNonemptySortedStringsV1("target parameter constraint values", parameter.Values); err != nil { + return err + } + continue + } + minimum, err := parseCanonicalIntegerV1("target parameter minimum", parameter.Minimum) + if err != nil { + return err + } + maximum, err := parseCanonicalIntegerV1("target parameter maximum", parameter.Maximum) + if err != nil { + return err + } + if minimum > maximum { + return fmt.Errorf("target parameter constraint %q range is inverted", parameter.Name) + } + if !boundedParameterRangeV1(minimum, maximum) { + return fmt.Errorf("target parameter constraint %q range exceeds the enumerable domain limit", parameter.Name) + } + } + return nil +} + +func validateParameterValuesV1(field string, values []ParameterValueV1) error { + if values == nil || len(values) > maxDefinitionReferences { + return fmt.Errorf("%s must use a bounded array", field) + } + for index, value := range values { + if !validRecordIdentifierV1(value.Name) || !validRecordTokenV1(value.Value) || index > 0 && values[index-1].Name >= value.Name { + return fmt.Errorf("%s must have unique sorted names and canonical values", field) + } + } + return nil +} + +func parseCanonicalIntegerV1(field string, value string) (int64, error) { + digits := value + if strings.HasPrefix(digits, "-") { + digits = strings.TrimPrefix(digits, "-") + if digits == "0" { + return 0, fmt.Errorf("%s must be a canonical bounded integer string", field) + } + } + if !canonicalDecimalPattern.MatchString(digits) { + return 0, fmt.Errorf("%s must be a canonical bounded integer string", field) + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, fmt.Errorf("%s must be a canonical bounded integer string", field) + } + return parsed, nil +} + +func boundedParameterRangeV1(minimum int64, maximum int64) bool { + if minimum > maximum { + return false + } + const maximumValues = int64(maxDefinitionReferences) + if minimum > int64(^uint64(0)>>1)-(maximumValues-1) { + return true + } + return maximum <= minimum+maximumValues-1 +} + +func validateTargetIdentityV1(target TargetIdentityV1) error { + if !validPlatformV1(target.Platform) || !validRecordIdentifierV1(target.OSReleaseID) || !validRecordSegmentV1(target.VersionID) || !supportedArchitectureV1(target.OCIArchitecture) || !supportedArchitectureV1(target.NativeArchitecture) || target.PackageManager != "apt" { + return fmt.Errorf("target identity is incomplete") + } + if target.Platform != "linux/"+target.OCIArchitecture || target.NativeArchitecture != target.OCIArchitecture { + return fmt.Errorf("target platform and OCI architecture are inconsistent") + } + return nil +} + +func validateTargetRecordIDV1(id string, target TargetIdentityV1) error { + segments := strings.Split(id, "/") + if len(segments) != 7 || segments[1] != "releases" || segments[3] != "targets" || segments[4] != target.OSReleaseID || segments[5] != target.VersionID || segments[6] != target.OCIArchitecture { + return fmt.Errorf("target record ID must use the complete tool release target namespace") + } + if _, err := decodeToolVersionSegmentV1(segments[2]); err != nil { + return fmt.Errorf("target record ID version: %w", err) + } + return nil +} + +func validateReleaseContractIDV1(id string) error { + segments := strings.Split(id, "/") + if len(segments) != 4 || segments[1] != "releases" || segments[3] != "contract" { + return fmt.Errorf("release contract ID must use tool:/releases//contract") + } + if _, err := decodeToolVersionSegmentV1(segments[2]); err != nil { + return fmt.Errorf("release contract ID version: %w", err) + } + return nil +} + +func validateBindingContractIDV1(id string, binding string) error { + segments := strings.Split(id, "/") + if len(segments) != 6 || segments[1] != "releases" || segments[3] != "bindings" || segments[4] != binding || segments[5] != "contract" { + return fmt.Errorf("binding contract ID must use tool:/releases//bindings/%s/contract", binding) + } + if _, err := decodeToolVersionSegmentV1(segments[2]); err != nil { + return fmt.Errorf("binding contract ID version: %w", err) + } + return nil +} + +func validateBindingArtifactIDV1(id string, binding string, platform string) error { + segments := strings.Split(id, "/") + expectedPlatform := strings.ReplaceAll(platform, "/", "-") + if len(segments) != 7 || segments[1] != "releases" || segments[3] != "bindings" || segments[4] != binding || segments[5] != "artifacts" || segments[6] != expectedPlatform { + return fmt.Errorf("binding artifact ID must match binding %q and platform %q in a release namespace", binding, platform) + } + if _, err := decodeToolVersionSegmentV1(segments[2]); err != nil { + return fmt.Errorf("binding artifact ID version: %w", err) + } + return nil +} + +func validateBindingArtifactCompatibilityV1(value *BindingArtifactRecordV1) error { + if err := requireNonemptySortedStringsV1("binding artifact tags", value.Tags); err != nil { + return err + } + filenameTags, err := wheelFilenameTagsV1(value.Filename) + if err != nil { + return fmt.Errorf("binding artifact filename: %w", err) + } + if compareRecordStringSlicesV1(filenameTags, value.Tags) != 0 { + return fmt.Errorf("binding artifact tags must exactly match the expanded wheel filename tags") + } + for _, tag := range value.Tags { + segments := strings.Split(tag, "-") + if len(segments) != 3 || !validWheelTagGroupV1(segments[0]) || !validWheelTagGroupV1(segments[1]) || !validWheelTagGroupV1(segments[2]) { + return fmt.Errorf("binding artifact wheel tag %q is invalid", tag) + } + if !wheelPlatformTagCompatibleV1(segments[2], value.Platform) { + return fmt.Errorf("binding artifact wheel tag %q is incompatible with platform %q", tag, value.Platform) + } + } + specifiers, err := pep440.NewSpecifiers(value.RequiresPython) + if err != nil || specifiers.String() != value.RequiresPython { + return fmt.Errorf("binding artifact requires_python must be a canonical PEP 440 specifier set") + } + return nil +} + +func wheelFilenameTagsV1(filename string) ([]string, error) { + if !strings.HasSuffix(filename, ".whl") { + return nil, fmt.Errorf("wheel filename must end in .whl") + } + parts := strings.Split(strings.TrimSuffix(filename, ".whl"), "-") + if len(parts) != 5 && len(parts) != 6 { + return nil, fmt.Errorf("wheel filename must contain distribution, version, Python, ABI, and platform tags") + } + if !validWheelDistributionV1(parts[0]) { + return nil, fmt.Errorf("wheel filename contains an invalid distribution or version") + } + version, err := pep440.Parse(parts[1]) + if err != nil || version.String() != parts[1] { + return nil, fmt.Errorf("wheel filename contains an invalid distribution or version") + } + if len(parts) == 6 && !validWheelBuildTagV1(parts[2]) { + return nil, fmt.Errorf("wheel filename contains an invalid build tag") + } + pythonTags := strings.Split(parts[len(parts)-3], ".") + abiTags := strings.Split(parts[len(parts)-2], ".") + platformTags := strings.Split(parts[len(parts)-1], ".") + expandedTagCount := 1 + for _, group := range [][]string{pythonTags, abiTags, platformTags} { + if len(group) > maxDefinitionReferences/expandedTagCount { + return nil, fmt.Errorf("wheel filename expands to more than %d compatibility tags", maxDefinitionReferences) + } + expandedTagCount *= len(group) + for _, component := range group { + if !validWheelTagComponentV1(component) { + return nil, fmt.Errorf("wheel filename contains an invalid compatibility tag") + } + } + } + tags := make([]string, 0, expandedTagCount) + for _, pythonTag := range pythonTags { + for _, abiTag := range abiTags { + for _, platformTag := range platformTags { + tags = append(tags, pythonTag+"-"+abiTag+"-"+platformTag) + } + } + } + sort.Strings(tags) + for index := 1; index < len(tags); index++ { + if tags[index-1] == tags[index] { + return nil, fmt.Errorf("wheel filename compatibility tags must be unique") + } + } + return tags, nil +} + +func validWheelDistributionV1(component string) bool { + if component == "" || component[0] == '_' { + return false + } + for _, character := range component { + if character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '_' { + continue + } + return false + } + return strings.ReplaceAll(pythonprovider.NormalizeDistributionName(component), "-", "_") == component +} + +func validWheelBuildTagV1(tag string) bool { + if tag == "" || tag[0] < '0' || tag[0] > '9' { + return false + } + for _, character := range tag[1:] { + if character >= 'A' && character <= 'Z' || character >= 'a' && character <= 'z' || character >= '0' && character <= '9' { + continue + } + return false + } + return true +} + +func validWheelTagGroupV1(group string) bool { + for _, component := range strings.Split(group, ".") { + if !validWheelTagComponentV1(component) { + return false + } + } + return true +} + +func validWheelTagComponentV1(component string) bool { + if component == "" { + return false + } + for _, character := range component { + if character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '_' { + continue + } + return false + } + return true +} + +func wheelPlatformTagCompatibleV1(tag string, platform string) bool { + if tag == "any" { + return true + } + architecture := "" + switch platform { + case "linux/amd64": + architecture = "x86_64" + case "linux/arm64": + architecture = "aarch64" + default: + return false + } + suffix := "_" + architecture + if !strings.HasSuffix(tag, suffix) { + return false + } + policy := strings.TrimSuffix(tag, suffix) + if policy == "linux" || policy == "manylinux2014" { + return true + } + if policy == "manylinux1" || policy == "manylinux2010" { + // PEP 513 and PEP 571 defined these policies for x86_64 and i686 only. + // aarch64 support first appears in manylinux2014 under PEP 599, so an + // ARM64 interpreter never selects a manylinux1 or manylinux2010 wheel. + return architecture == "x86_64" + } + if components, found := strings.CutPrefix(policy, "manylinux_"); found { + parts := strings.Split(components, "_") + return len(parts) == 2 && canonicalDecimalPattern.MatchString(parts[0]) && canonicalDecimalPattern.MatchString(parts[1]) + } + return false +} + +func validateArtifactSourceIDV1(id string) error { + segments := strings.Split(id, "/") + if len(segments) != 7 || segments[1] != "releases" || segments[3] != "revisions" || segments[5] != "sources" || !validRecordIdentifierV1(segments[6]) { + return fmt.Errorf("artifact source ID must use a release revision source namespace") + } + if _, err := decodeToolVersionSegmentV1(segments[2]); err != nil { + return fmt.Errorf("artifact source ID version: %w", err) + } + if err := validateCanonicalDecimalV1("artifact source ID revision", segments[4], true); err != nil { + return err + } + return nil +} + +func validateNativePackageSetIDV1(id string) error { + segments := strings.Split(id, "/") + if len(segments) != 5 || segments[1] != "releases" || segments[3] != "package-sets" || !validRecordIdentifierV1(segments[4]) { + return fmt.Errorf("native package-set ID must use a release package-set namespace") + } + if _, err := decodeToolVersionSegmentV1(segments[2]); err != nil { + return fmt.Errorf("native package-set ID version: %w", err) + } + return nil +} + +func validPackageNameV1(value string) bool { + if value == "" || value[0] < 'a' || value[0] > 'z' { + return false + } + for _, character := range value[1:] { + if character >= 'a' && character <= 'z' || character >= '0' && character <= '9' { + continue + } + switch character { + case '.', '-', '_': + default: + return false + } + } + return true +} + +func supportedArchitectureV1(value string) bool { + return value == "amd64" || value == "arm64" +} + +func validPlatformV1(value string) bool { + return value == "linux/amd64" || value == "linux/arm64" +} + +func supportedPayloadKindV1(value string) bool { + return value == "jdk-archive" || value == "playwright-browser-archive" || value == "raw-executable" +} + +func validEnvironmentNameV1(value string) bool { + if value == "" || value[0] < 'A' || value[0] > 'Z' { + return false + } + for _, character := range value[1:] { + if character < 'A' || character > 'Z' { + if character < '0' || character > '9' { + if character != '_' { + return false + } + } + } + } + return true +} + +func selectionSetCompatibleV1(selections []string, groups [][]string) bool { + if len(selections) == 0 { + return true + } + for _, group := range groups { + if recordStringSliceSubsetV1(selections, group) { + return true + } + } + return false +} diff --git a/internal/toolcatalog/records_validate_test.go b/internal/toolcatalog/records_validate_test.go new file mode 100644 index 00000000..d7d3f137 --- /dev/null +++ b/internal/toolcatalog/records_validate_test.go @@ -0,0 +1,956 @@ +package toolcatalog + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "testing" +) + +func TestValidateLoadedRecordV1RejectsInvalidFieldsBySchema(t *testing.T) { + values := validRecordValuesV1() + tests := []struct { + name string + value any + want string + }{ + {name: "tool URL query", value: func() any { value := *(values[0].(*ToolRecordV1)); value.Source += "?token=secret"; return &value }(), want: "credential-free HTTPS"}, + {name: "tool version scheme", value: func() any { value := *(values[0].(*ToolRecordV1)); value.VersionScheme = "debian"; return &value }(), want: "version scheme is unsupported"}, + {name: "ordered default version", value: func() any { value := *(values[0].(*ToolRecordV1)); value.DefaultVersion = "1.2.3"; return &value }(), want: "must not declare"}, + {name: "opaque default version", value: func() any { + value := *(values[0].(*ToolRecordV1)) + value.VersionScheme = "opaque" + return &value + }(), want: "requires a canonical default"}, + {name: "manifest revision", value: func() any { value := *(values[1].(*ReleaseManifestV1)); value.Revision = "01"; return &value }(), want: "canonical decimal"}, + {name: "manifest duplicate alias", value: func() any { + value := *(values[1].(*ReleaseManifestV1)) + value.Aliases = []string{"1.2", "1.2"} + return &value + }(), want: "unique sorted"}, + {name: "manifest exact alias", value: func() any { + value := *(values[1].(*ReleaseManifestV1)) + value.Aliases = []string{"1.2.3"} + return &value + }(), want: "redundantly equals"}, + {name: "manifest unencoded version ID", value: func() any { + value := *(values[1].(*ReleaseManifestV1)) + value.Version = "1!2" + return &value + }(), want: "release manifest ID must be"}, + {name: "manifest noncanonical provenance", value: func() any { + value := *(values[1].(*ReleaseManifestV1)) + value.Provenance = []string{"https://example.com/a", "https://example.com/%61"} + return &value + }(), want: "canonical spelling"}, + {name: "manifest contract outside release", value: func() any { + value := *(values[1].(*ReleaseManifestV1)) + value.Contract.ID = "tool:demo/releases/2.0.0/contract" + return &value + }(), want: "current release contract"}, + {name: "manifest target outside tool", value: func() any { + value := *(values[1].(*ReleaseManifestV1)) + value.Targets = append([]RecordReferenceV1{}, value.Targets...) + value.Targets[0].ID = "tool:other/releases/1.2.3/targets/debian/12/amd64" + return &value + }(), want: "must name a target record"}, + {name: "manifest source outside revision", value: func() any { + value := *(values[1].(*ReleaseManifestV1)) + value.ArtifactSources = []ArtifactSourceMappingV1{{ + ArtifactSHA256: recordTestDigest, + Artifact: recordTestReference("tool:demo/releases/1.2.3/payloads/demo-linux-amd64"), + Source: recordTestReference("tool:demo/releases/1.2.3/revisions/2/sources/demo-linux-amd64"), + }} + return &value + }(), want: "in revision"}, + {name: "manifest source appends segments to a source record", value: func() any { + value := *(values[1].(*ReleaseManifestV1)) + value.ArtifactSources = []ArtifactSourceMappingV1{{ + ArtifactSHA256: recordTestDigest, + Artifact: recordTestReference("tool:demo/releases/1.2.3/payloads/demo-linux-amd64"), + Source: recordTestReference("tool:demo/releases/1.2.3/revisions/1/sources/demo/extra"), + }} + return &value + }(), want: "artifact source record"}, + {name: "manifest source leaf is not an identifier", value: func() any { + value := *(values[1].(*ReleaseManifestV1)) + value.ArtifactSources = []ArtifactSourceMappingV1{{ + ArtifactSHA256: recordTestDigest, + Artifact: recordTestReference("tool:demo/releases/1.2.3/payloads/demo-linux-amd64"), + Source: recordTestReference("tool:demo/releases/1.2.3/revisions/1/sources/Demo"), + }} + return &value + }(), want: "artifact source record"}, + {name: "manifest source mapping names a nonartifact record", value: func() any { + value := *(values[1].(*ReleaseManifestV1)) + value.ArtifactSources = []ArtifactSourceMappingV1{{ + ArtifactSHA256: recordTestDigest, + Artifact: recordTestReference("tool:demo/releases/1.2.3/contract"), + Source: recordTestReference("tool:demo/releases/1.2.3/revisions/1/sources/demo-linux-amd64"), + }} + return &value + }(), want: "payload or binding artifact"}, + {name: "manifest source mapping names a binding contract", value: func() any { + value := *(values[1].(*ReleaseManifestV1)) + value.ArtifactSources = []ArtifactSourceMappingV1{{ + ArtifactSHA256: recordTestDigest, + Artifact: recordTestReference("tool:demo/releases/1.2.3/bindings/python/contract"), + Source: recordTestReference("tool:demo/releases/1.2.3/revisions/1/sources/demo-linux-amd64"), + }} + return &value + }(), want: "payload or binding artifact"}, + {name: "manifest source mapping names an impossible binding artifact", value: func() any { + value := *(values[1].(*ReleaseManifestV1)) + value.ArtifactSources = []ArtifactSourceMappingV1{{ + ArtifactSHA256: recordTestDigest, + Artifact: recordTestReference("tool:demo/releases/1.2.3/bindings/python/artifacts/contract"), + Source: recordTestReference("tool:demo/releases/1.2.3/revisions/1/sources/demo-linux-amd64"), + }} + return &value + }(), want: "payload or binding artifact"}, + {name: "manifest source mapping appends segments to a binding artifact", value: func() any { + value := *(values[1].(*ReleaseManifestV1)) + value.ArtifactSources = []ArtifactSourceMappingV1{{ + ArtifactSHA256: recordTestDigest, + Artifact: recordTestReference("tool:demo/releases/1.2.3/bindings/python/artifacts/linux-amd64/extra"), + Source: recordTestReference("tool:demo/releases/1.2.3/revisions/1/sources/demo-linux-amd64"), + }} + return &value + }(), want: "payload or binding artifact"}, + {name: "manifest source mapping payload leaf lacks a platform", value: func() any { + value := *(values[1].(*ReleaseManifestV1)) + value.ArtifactSources = []ArtifactSourceMappingV1{{ + ArtifactSHA256: recordTestDigest, + Artifact: recordTestReference("tool:demo/releases/1.2.3/payloads/demo"), + Source: recordTestReference("tool:demo/releases/1.2.3/revisions/1/sources/demo-linux-amd64"), + }} + return &value + }(), want: "payload or binding artifact"}, + {name: "contract context", value: func() any { + value := *(values[2].(*ReleaseContractV1)) + value.Contexts = []string{"install"} + return &value + }(), want: "unsupported"}, + {name: "contract supported Reploy", value: func() any { + value := *(values[2].(*ReleaseContractV1)) + value.SupportedReploy = ">= 0.0" + return &value + }(), want: "canonical SemVer"}, + {name: "contract ID", value: func() any { + value := *(values[2].(*ReleaseContractV1)) + value.ID = "tool:demo/releases/1.2.3/payloads/contract" + return &value + }(), want: "release contract ID must use"}, + {name: "target ID", value: func() any { value := *(values[3].(*TargetRecordV1)); value.ID += "-wrong"; return &value }(), want: "complete tool release target namespace"}, + {name: "target unrelated prefix", value: func() any { + value := *(values[3].(*TargetRecordV1)) + value.ID = "tool:demo/unrelated/targets/debian/12/amd64" + return &value + }(), want: "tool release namespace"}, + {name: "target missing fixtures", value: func() any { + value := *(values[3].(*TargetRecordV1)) + value.IntegrationFixtures = []RecordReferenceV1{} + return &value + }(), want: "must not be empty"}, + {name: "target payload outside release", value: func() any { + value := *(values[3].(*TargetRecordV1)) + value.Payloads = append([]RecordReferenceV1{}, value.Payloads...) + value.Payloads[0].ID = "tool:demo/releases/2.0.0/payloads/demo-linux-amd64" + return &value + }(), want: "must name a payload record"}, + {name: "empty target selection", value: func() any { + value := *(values[3].(*TargetRecordV1)) + value.Selections = []TargetSelectionV1{{Name: "browser", Payloads: []RecordReferenceV1{}, PackageSets: []RecordReferenceV1{}, Exports: []ToolExportV1{}, Probes: []RecordProbeV1{}}} + return &value + }(), want: "must contribute"}, + {name: "binding requirements", value: func() any { + value := *(values[4].(*BindingContractV1)) + value.Requirements = []string{"support>=1,<2", "demo==1.2.3"} + return &value + }(), want: "unique sorted"}, + {name: "binding package-manager option", value: func() any { + value := *(values[4].(*BindingContractV1)) + value.Requirements = []string{"--index-url=https://example.invalid/simple"} + return &value + }(), want: "must not be a package-manager option"}, + {name: "binding malformed requirement", value: func() any { + value := *(values[4].(*BindingContractV1)) + value.Requirements = []string{"demo ???"} + return &value + }(), want: "must not contain whitespace"}, + {name: "binding malformed distribution", value: func() any { + value := *(values[4].(*BindingContractV1)) + value.Requirements = []string{"demo-"} + return &value + }(), want: "invalid Python package root requirement"}, + {name: "conflicting binding requirements", value: func() any { + value := *(values[4].(*BindingContractV1)) + value.Requirements = []string{"demo==1", "demo==2"} + return &value + }(), want: "name the same distribution"}, + {name: "binding malformed supported Python", value: func() any { + value := *(values[4].(*BindingContractV1)) + value.SupportedPython = []string{"banana"} + return &value + }(), want: "must use major.minor"}, + {name: "binding contract ID", value: func() any { + value := *(values[4].(*BindingContractV1)) + value.ID = "tool:demo" + return &value + }(), want: "binding contract ID must use"}, + {name: "binding artifact size", value: func() any { value := *(values[5].(*BindingArtifactRecordV1)); value.Size = "042"; return &value }(), want: "canonical decimal"}, + {name: "binding artifact ID", value: func() any { + value := *(values[5].(*BindingArtifactRecordV1)) + value.ID = "tool:demo" + return &value + }(), want: "must match binding"}, + {name: "binding artifact arbitrary tag", value: func() any { + value := *(values[5].(*BindingArtifactRecordV1)) + value.Tags = []string{"anything"} + return &value + }(), want: "exactly match"}, + {name: "binding artifact wrong platform", value: func() any { + value := *(values[5].(*BindingArtifactRecordV1)) + value.Filename = "demo-1.2.3-py3-none-win_amd64.whl" + value.Tags = []string{"py3-none-win_amd64"} + return &value + }(), want: "incompatible with platform"}, + {name: "binding artifact musllinux platform", value: func() any { + value := *(values[5].(*BindingArtifactRecordV1)) + value.Filename = "demo-1.2.3-py3-none-musllinux_1_2_x86_64.whl" + value.Tags = []string{"py3-none-musllinux_1_2_x86_64"} + return &value + }(), want: "incompatible with platform"}, + {name: "binding artifact requires Python", value: func() any { + value := *(values[5].(*BindingArtifactRecordV1)) + value.RequiresPython = "banana" + return &value + }(), want: "canonical PEP 440"}, + {name: "binding artifact malformed wheel filename", value: func() any { + value := *(values[5].(*BindingArtifactRecordV1)) + value.Filename = "demo-1-extra-build-py3-none-manylinux1_x86_64.whl" + return &value + }(), want: "must contain distribution"}, + {name: "binding artifact malformed wheel version", value: func() any { + value := *(values[5].(*BindingArtifactRecordV1)) + value.Filename = "demo-banana-py3-none-manylinux1_x86_64.whl" + return &value + }(), want: "invalid distribution or version"}, + {name: "binding artifact oversized compressed tags", value: func() any { + value := *(values[5].(*BindingArtifactRecordV1)) + pythonTags := strings.TrimSuffix(strings.Repeat("py3.", maxDefinitionReferences+1), ".") + value.Filename = "demo-1.2.3-" + pythonTags + "-none-manylinux1_x86_64.whl" + return &value + }(), want: "expands to more than"}, + {name: "payload escape", value: func() any { value := *(values[6].(*PayloadRecordV1)); value.Executable = "../chrome"; return &value }(), want: "invalid segment"}, + {name: "payload ID", value: func() any { + value := *(values[6].(*PayloadRecordV1)) + value.ID = "tool:demo/releases/1.2.3/bindings/chromium" + return &value + }(), want: "release payload namespace"}, + {name: "source duplicate mirror", value: func() any { + value := *(values[7].(*ArtifactSourceRecordV1)) + value.Mirrors = []string{"https://example.com/a", "https://example.com/b", "https://example.com/a"} + return &value + }(), want: "must be unique"}, + {name: "source noncanonical mirror", value: func() any { + value := *(values[7].(*ArtifactSourceRecordV1)) + value.Mirrors = []string{"https://example.com/a", "https://example.com/%61"} + return &value + }(), want: "canonical spelling"}, + {name: "source noncanonical provenance", value: func() any { + value := *(values[7].(*ArtifactSourceRecordV1)) + value.Provenance = []string{"https://example.com/a", "https://example.com/%61"} + return &value + }(), want: "canonical spelling"}, + {name: "source ID", value: func() any { + value := *(values[7].(*ArtifactSourceRecordV1)) + value.ID = "tool:demo" + return &value + }(), want: "release revision source namespace"}, + {name: "package manager", value: func() any { value := *(values[8].(*NativePackageSetV1)); value.Manager = "dnf"; return &value }(), want: "identity is incomplete"}, + {name: "package requirement constraint", value: func() any { + value := *(values[8].(*NativePackageSetV1)) + value.Requirements = []string{"libfoo>=1"} + return &value + }(), want: "exact Debian binary package name"}, + {name: "package requirement option", value: func() any { + value := *(values[8].(*NativePackageSetV1)) + value.Requirements = []string{"--allow-unauthenticated"} + return &value + }(), want: "exact Debian binary package name"}, + {name: "conflicting package requirements", value: func() any { + value := *(values[8].(*NativePackageSetV1)) + value.Requirements = []string{"libfoo=1", "libfoo=2"} + return &value + }(), want: "name the same package"}, + {name: "package set ID", value: func() any { + value := *(values[8].(*NativePackageSetV1)) + value.ID = "tool:demo" + return &value + }(), want: "release package-set namespace"}, + {name: "fixture base image", value: func() any { + value := *(values[9].(*IntegrationFixtureRecordV1)) + value.BaseImage = "https://example.com/image:tag" + return &value + }(), want: "canonical tagged OCI reference"}, + {name: "fixture name mismatch", value: func() any { + value := *(values[9].(*IntegrationFixtureRecordV1)) + value.ID = "tool:demo/releases/1.2.3/validation/fixtures/other" + return &value + }(), want: "use its name"}, + {name: "profile network", value: func() any { + value := *(values[10].(*ValidationProfileRecordV1)) + value.Network = "default" + return &value + }(), want: "disable networking"}, + {name: "profile validator version", value: func() any { + value := *(values[10].(*ValidationProfileRecordV1)) + value.ValidatorVersion = "" + return &value + }(), want: "validator version"}, + {name: "profile missing probes", value: func() any { + value := *(values[10].(*ValidationProfileRecordV1)) + value.Probes = []RecordProbeV1{} + return &value + }(), want: "nonempty bounded array"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + record := loadedRecordV1{ID: recordIDV1(test.value), Schema: recordSchemaV1(test.value), Value: test.value} + err := validateLoadedRecordV1(record) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want substring %q", err, test.want) + } + }) + } + valid := values[0].(*ToolRecordV1) + if err := validateLoadedRecordV1(loadedRecordV1{ID: valid.ID, Schema: ReleaseContractSchemaV1, Value: valid}); err == nil || !strings.Contains(err.Error(), "identity is inconsistent") { + t.Fatalf("mismatched loaded schema error = %v", err) + } +} + +func TestRecordCollectionLimitsV1(t *testing.T) { + if err := validateReferenceListV1("references", nil); err == nil { + t.Fatal("nil reference list was accepted") + } + references := make([]RecordReferenceV1, maxDefinitionReferences+1) + if err := validateReferenceListV1("references", references); err == nil || !strings.Contains(err.Error(), "at most") { + t.Fatalf("oversized reference error = %v", err) + } + values := validRecordValuesV1() + source := *(values[7].(*ArtifactSourceRecordV1)) + source.Mirrors = make([]string, maxDefinitionArtifactMirrors+1) + if err := validateLoadedRecordV1(loadedRecordV1{ID: source.ID, Schema: source.Schema, Value: &source}); err == nil || !strings.Contains(err.Error(), "between 1 and") { + t.Fatalf("oversized mirror error = %v", err) + } + packages := *(values[8].(*NativePackageSetV1)) + packages.Requirements = []string{"bad\nrequirement"} + if err := validateLoadedRecordV1(loadedRecordV1{ID: packages.ID, Schema: packages.Schema, Value: &packages}); err == nil || !strings.Contains(err.Error(), "canonical values") { + t.Fatalf("control-character requirement error = %v", err) + } + binding := *(values[4].(*BindingContractV1)) + binding.Package = "bad package" + if err := validateLoadedRecordV1(loadedRecordV1{ID: binding.ID, Schema: binding.Schema, Value: &binding}); err == nil || !strings.Contains(err.Error(), "incomplete") { + t.Fatalf("invalid binding package error = %v", err) + } + payload := *(values[6].(*PayloadRecordV1)) + payload.Revision = "bad\nrevision" + if err := validateLoadedRecordV1(loadedRecordV1{ID: payload.ID, Schema: payload.Schema, Value: &payload}); err == nil || !strings.Contains(err.Error(), "identity is incomplete") { + t.Fatalf("invalid payload revision error = %v", err) + } +} + +func TestToolRecordAcceptsOpaqueDefaultVersion(t *testing.T) { + value := *(validRecordValuesV1()[0].(*ToolRecordV1)) + value.VersionScheme = "opaque" + value.DefaultVersion = "latest!vetted" + // The default must name an advertised release, so the release it names is + // the one this record advertises. + segment, err := encodeToolVersionSegmentV1(value.DefaultVersion) + if err != nil { + t.Fatal(err) + } + value.Releases = []RecordReferenceV1{recordTestReference("tool:demo/releases/" + segment + "/revisions/1/manifest")} + if err := validateLoadedRecordV1(loadedRecordV1{ID: value.ID, Schema: value.Schema, Value: &value}); err != nil { + t.Fatal(err) + } +} + +func TestNewRecordFieldsAreValidated(t *testing.T) { + // Fields added to the record model during review must be constrained here, + // not merely declared. Each case mutates one valid record and expects a + // diagnostic naming that field. + for _, testCase := range []struct { + name string + mutate func(any) + index int + wantSub string + }{ + {name: "tool documentation empty", index: 0, wantSub: "must not be empty", + mutate: func(v any) { v.(*ToolRecordV1).Documentation = "" }}, + {name: "tool documentation not a canonical URL", index: 0, wantSub: "reference URL", + mutate: func(v any) { v.(*ToolRecordV1).Documentation = "http://example.com/docs" }}, + {name: "binding supported tag malformed", index: 4, wantSub: "canonical three-part wheel tag", + mutate: func(v any) { v.(*BindingContractV1).SupportedTags = []string{"py3-none"} }}, + {name: "binding bundled components unsorted", index: 4, wantSub: "unique and sorted", + mutate: func(v any) { + v.(*BindingContractV1).BundledComponents = []BundledComponentV1{ + {Name: "zeta", Version: "1", Path: "z"}, {Name: "alpha", Version: "1", Path: "a"}} + }}, + {name: "binding artifact resolver unsupported", index: 5, wantSub: "resolver", + mutate: func(v any) { v.(*BindingArtifactRecordV1).Resolver = "ftp" }}, + {name: "binding artifact ecosystem version noncanonical", index: 5, wantSub: "ecosystem version", + mutate: func(v any) { v.(*BindingArtifactRecordV1).EcosystemVersion = "1 2" }}, + {name: "binding artifact contract outside its binding", index: 5, wantSub: "contract reference must be", + mutate: func(v any) { + v.(*BindingArtifactRecordV1).Contract = recordTestReference("tool:demo/releases/1.2.3/bindings/node/contract") + }}, + {name: "binding artifact name disagrees with filename", index: 5, wantSub: "must match the wheel filename", + mutate: func(v any) { v.(*BindingArtifactRecordV1).Name = "other" }}, + {name: "binding artifact ecosystem version disagrees with filename", index: 5, wantSub: "must match the wheel filename", + mutate: func(v any) { v.(*BindingArtifactRecordV1).EcosystemVersion = "9.9.9" }}, + {name: "payload resolver unsupported", index: 6, wantSub: "resolver", + mutate: func(v any) { v.(*PayloadRecordV1).Resolver = "" }}, + {name: "fixture selection capitalized", index: 9, wantSub: "canonical identifiers", + mutate: func(v any) { v.(*IntegrationFixtureRecordV1).Selections = []string{"Chromium"} }}, + {name: "fixture selection contains a space", index: 9, wantSub: "canonical identifiers", + mutate: func(v any) { v.(*IntegrationFixtureRecordV1).Selections = []string{"bad selection"} }}, + {name: "artifact source diagnostics unsorted", index: 7, wantSub: "diagnostics", + mutate: func(v any) { v.(*ArtifactSourceRecordV1).Diagnostics = []string{"b", "a"} }}, + {name: "package set repositories unsorted", index: 8, wantSub: "repositories", + mutate: func(v any) { v.(*NativePackageSetV1).Repositories = []string{"b", "a"} }}, + {name: "package set validation metadata unsorted", index: 8, wantSub: "validation metadata", + mutate: func(v any) { v.(*NativePackageSetV1).ValidationMetadata = []string{"b", "a"} }}, + } { + t.Run(testCase.name, func(t *testing.T) { + values := validRecordValuesV1() + value := values[testCase.index] + testCase.mutate(value) + err := validateLoadedRecordV1(loadedRecordV1{ID: recordIDV1(value), Schema: recordSchemaV1(value), Value: value}) + if err == nil || !strings.Contains(err.Error(), testCase.wantSub) { + t.Fatalf("error = %v, want substring %q", err, testCase.wantSub) + } + }) + } +} + +func TestReleaseAliasesFollowTheVersionRule(t *testing.T) { + // An alias is an alternative version coordinate, so it must accept exactly + // what the version field accepts, including scheme-native forms. + value := *(validRecordValuesV1()[1].(*ReleaseManifestV1)) + value.Aliases = []string{"1!2", "1.2"} + if err := validateLoadedRecordV1(loadedRecordV1{ID: value.ID, Schema: value.Schema, Value: &value}); err != nil { + t.Fatalf("scheme-native alias rejected: %v", err) + } + + tooMany := make([]string, maxDefinitionReferences+1) + for index := range tooMany { + tooMany[index] = fmt.Sprintf("%06d", index) + } + value.Aliases = tooMany + err := validateLoadedRecordV1(loadedRecordV1{ID: value.ID, Schema: value.Schema, Value: &value}) + if err == nil || !strings.Contains(err.Error(), "at most") { + t.Errorf("oversized alias list error = %v", err) + } +} + +// The design requires an opaque default_version to name one advertised release, +// because a versionless opaque request normalizes to equality with it. +func TestOpaqueDefaultVersionMustNameAnAdvertisedReleaseV1(t *testing.T) { + opaque := func(defaultVersion string, coordinates ...string) *ToolRecordV1 { + value := *(validRecordValuesV1()[0].(*ToolRecordV1)) + value.VersionScheme = "opaque" + value.DefaultVersion = defaultVersion + value.Releases = nil + for _, coordinate := range coordinates { + segment, err := encodeToolVersionSegmentV1(coordinate) + if err != nil { + t.Fatalf("encodeToolVersionSegmentV1(%q): %v", coordinate, err) + } + value.Releases = append(value.Releases, recordTestReference("tool:demo/releases/"+segment+"/revisions/1/manifest")) + } + return &value + } + for _, testCase := range []struct { + name string + value *ToolRecordV1 + wantAcceptance bool + }{ + {name: "default is advertised", value: opaque("2024ru1", "2024ru1"), wantAcceptance: true}, + {name: "default is one of several", value: opaque("2024ru1", "2023ru9", "2024ru1"), wantAcceptance: true}, + {name: "default names no release", value: opaque("2024ru2", "2024ru1")}, + } { + t.Run(testCase.name, func(t *testing.T) { + err := validateLoadedRecordV1(loadedRecordV1{ID: testCase.value.ID, Schema: testCase.value.Schema, Value: testCase.value}) + if testCase.wantAcceptance { + if err != nil { + t.Errorf("rejected: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), "must name an advertised release") { + t.Errorf("error = %v, want an unadvertised-default rejection", err) + } + }) + } +} + +// targetBindingWithArtifactV1 builds the one advertised binding entry with a +// caller-chosen artifact reference, so a test can vary only that reference. +func targetBindingWithArtifactV1(artifactID string) []TargetBindingV1 { + const release = "tool:demo/releases/1.2.3" + return []TargetBindingV1{{ + Name: "python", + Contract: recordTestReference(release + "/bindings/python/contract"), + Artifacts: []RecordReferenceV1{recordTestReference(artifactID)}, + PackageSets: []RecordReferenceV1{}, + Exports: []ToolExportV1{}, + Probes: []RecordProbeV1{}, + }} +} + +// A namespace prefix alone admits IDs no record can own. Every cross-record +// reference must be rejected unless it matches the shape its owning record's ID +// validator enforces. +func TestCrossRecordReferencesRequireOwnableIDsV1(t *testing.T) { + values := validRecordValuesV1() + const release = "tool:demo/releases/1.2.3" + for _, testCase := range []struct { + name string + value any + want string + }{ + {name: "release target with extra segments", value: func() any { + value := *(values[1].(*ReleaseManifestV1)) + value.Targets = []RecordReferenceV1{recordTestReference(release + "/targets/debian/12/amd64/extra")} + return &value + }(), want: "must name a target record"}, + {name: "release target with unsupported architecture", value: func() any { + value := *(values[1].(*ReleaseManifestV1)) + value.Targets = []RecordReferenceV1{recordTestReference(release + "/targets/debian/12/sparc")} + return &value + }(), want: "must name a target record"}, + {name: "tool release coordinate violates the version scheme", value: func() any { + value := *(values[0].(*ToolRecordV1)) + value.Releases = []RecordReferenceV1{recordTestReference("tool:demo/releases/banana/revisions/1/manifest")} + return &value + }(), want: "not canonical SemVer"}, + {name: "tool release revision is not canonical", value: func() any { + value := *(values[0].(*ToolRecordV1)) + value.Releases = []RecordReferenceV1{recordTestReference("tool:demo/releases/1.2.3/revisions/latest/manifest")} + return &value + }(), want: "revision"}, + {name: "tool release revision is zero", value: func() any { + value := *(values[0].(*ToolRecordV1)) + value.Releases = []RecordReferenceV1{recordTestReference("tool:demo/releases/1.2.3/revisions/0/manifest")} + return &value + }(), want: "revision"}, + {name: "target integration fixture leaf is not an identifier", value: func() any { + value := *(values[3].(*TargetRecordV1)) + value.IntegrationFixtures = []RecordReferenceV1{recordTestReference(release + "/validation/fixtures/debian.12")} + return &value + }(), want: "must name a fixture record"}, + {name: "release validation profile appends a segment", value: func() any { + value := *(values[1].(*ReleaseManifestV1)) + value.ValidationProfile = recordTestReference(release + "/validation/profiles/default/extra") + return &value + }(), want: "release validation profile"}, + {name: "release validation profile with a nondefault leaf", value: func() any { + value := *(values[1].(*ReleaseManifestV1)) + value.ValidationProfile = recordTestReference(release + "/validation/profiles/other") + return &value + }(), want: "release validation profile"}, + {name: "target package set with extra segments", value: func() any { + value := *(values[3].(*TargetRecordV1)) + value.PackageSets = []RecordReferenceV1{recordTestReference(release + "/package-sets/base/extra")} + return &value + }(), want: "must name a native package-set record"}, + {name: "target payload leaf lacks a platform", value: func() any { + value := *(values[3].(*TargetRecordV1)) + value.Payloads = []RecordReferenceV1{recordTestReference(release + "/payloads/demo")} + return &value + }(), want: "must name a payload record"}, + {name: "target integration fixture with extra segments", value: func() any { + value := *(values[3].(*TargetRecordV1)) + value.IntegrationFixtures = []RecordReferenceV1{recordTestReference(release + "/validation/fixtures/debian-12-amd64/extra")} + return &value + }(), want: "must name a fixture record"}, + {name: "target binding artifact of another binding", value: func() any { + value := *(values[3].(*TargetRecordV1)) + value.Bindings = targetBindingWithArtifactV1(release + "/bindings/other/artifacts/linux-amd64") + return &value + }(), want: "must name an artifact of binding"}, + {name: "target binding artifact with a nonplatform leaf", value: func() any { + value := *(values[3].(*TargetRecordV1)) + value.Bindings = targetBindingWithArtifactV1(release + "/bindings/python/artifacts/contract") + return &value + }(), want: "must name an artifact of binding"}, + {name: "target binding package set with extra segments", value: func() any { + value := *(values[3].(*TargetRecordV1)) + bindings := targetBindingWithArtifactV1(release + "/bindings/python/artifacts/linux-amd64") + bindings[0].PackageSets = []RecordReferenceV1{recordTestReference(release + "/package-sets/base/extra")} + value.Bindings = bindings + return &value + }(), want: "must name a native package-set record"}, + } { + t.Run(testCase.name, func(t *testing.T) { + err := validateLoadedRecordV1(loadedRecordV1{ID: recordIDV1(testCase.value), Schema: recordSchemaV1(testCase.value), Value: testCase.value}) + if err == nil || !strings.Contains(err.Error(), testCase.want) { + t.Errorf("error = %v, want substring %q", err, testCase.want) + } + }) + } +} + +// Every sorted string collection carries the same per-collection bound as the +// reference lists, so a caller cannot bypass it by choosing a string field. +func TestSortedStringCollectionsAreBoundedV1(t *testing.T) { + tooMany := make([]string, maxDefinitionReferences+1) + for index := range tooMany { + tooMany[index] = fmt.Sprintf("%06d", index) + } + values := validRecordValuesV1() + for _, testCase := range []struct { + name string + value any + }{ + {name: "binding requirements", value: func() any { + value := *(values[4].(*BindingContractV1)) + value.Requirements = tooMany + return &value + }()}, + {name: "supported Python", value: func() any { + value := *(values[4].(*BindingContractV1)) + value.SupportedPython = tooMany + return &value + }()}, + {name: "binding supported tags", value: func() any { + value := *(values[4].(*BindingContractV1)) + value.SupportedTags = tooMany + return &value + }()}, + {name: "native package requirements", value: func() any { + value := *(values[8].(*NativePackageSetV1)) + value.Requirements = tooMany + return &value + }()}, + {name: "native package repositories", value: func() any { + value := *(values[8].(*NativePackageSetV1)) + value.Repositories = tooMany + return &value + }()}, + {name: "binding artifact tags", value: func() any { + value := *(values[5].(*BindingArtifactRecordV1)) + value.Tags = tooMany + return &value + }()}, + {name: "integration fixture selections", value: func() any { + value := *(values[9].(*IntegrationFixtureRecordV1)) + value.Selections = tooMany + return &value + }()}, + } { + t.Run(testCase.name, func(t *testing.T) { + err := validateLoadedRecordV1(loadedRecordV1{ID: recordIDV1(testCase.value), Schema: recordSchemaV1(testCase.value), Value: testCase.value}) + if err == nil || !strings.Contains(err.Error(), "at most") { + t.Errorf("oversized %s error = %v", testCase.name, err) + } + }) + } +} + +func TestReleaseManifestAcceptsEncodedSchemeNativeVersion(t *testing.T) { + value := *(validRecordValuesV1()[1].(*ReleaseManifestV1)) + value.Targets = append([]RecordReferenceV1{}, value.Targets...) + value.Version = "1!2" + prefix := "tool:demo/releases/1%212" + value.ID = prefix + "/revisions/1/manifest" + value.Contract.ID = prefix + "/contract" + value.ValidationProfile.ID = prefix + "/validation/profiles/default" + value.Targets[0].ID = prefix + "/targets/debian/12/amd64" + if err := validateLoadedRecordV1(loadedRecordV1{ID: value.ID, Schema: value.Schema, Value: &value}); err != nil { + t.Fatal(err) + } +} + +func TestReleaseContractAcceptsEncodedSchemeNativeVersion(t *testing.T) { + value := *(validRecordValuesV1()[2].(*ReleaseContractV1)) + value.ID = "tool:demo/releases/1%212/contract" + if err := validateLoadedRecordV1(loadedRecordV1{ID: value.ID, Schema: value.Schema, Value: &value}); err != nil { + t.Fatal(err) + } +} + +func TestValidationProfileAcceptsEncodedSchemeNativeVersion(t *testing.T) { + value := *(validRecordValuesV1()[10].(*ValidationProfileRecordV1)) + value.Version = "1!2" + value.ID = "tool:demo/releases/1%212/validation/profiles/default" + if err := validateLoadedRecordV1(loadedRecordV1{ID: value.ID, Schema: value.Schema, Value: &value}); err != nil { + t.Fatal(err) + } +} + +func TestValidateRequestPoliciesV1(t *testing.T) { + validBinding := BindingRequestV1{Options: []string{"python"}, Required: true, Default: ""} + if err := validateBindingRequestV1(validBinding); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + binding BindingRequestV1 + want string + }{ + {name: "nil options", binding: BindingRequestV1{Options: nil}, want: "must use an array"}, + {name: "required empty", binding: BindingRequestV1{Options: []string{}, Required: true}, want: "at least one option"}, + {name: "invalid option", binding: BindingRequestV1{Options: []string{"Python"}}, want: "canonical identifiers"}, + {name: "unknown default", binding: BindingRequestV1{Options: []string{"python"}, Default: "node"}, want: "default binding"}, + } { + t.Run("binding "+test.name, func(t *testing.T) { + err := validateBindingRequestV1(test.binding) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want substring %q", err, test.want) + } + }) + } + validSelections := SelectionRequestV1{ + Options: []string{"chromium", "webkit"}, Minimum: "1", Maximum: "2", Defaults: []string{}, + CompatibilityGroups: [][]string{{"chromium", "webkit"}}, + } + if err := validateSelectionRequestV1(validSelections); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + selections SelectionRequestV1 + want string + }{ + {name: "noncanonical minimum", selections: SelectionRequestV1{Options: []string{}, Minimum: "01", Maximum: "0", Defaults: []string{}, CompatibilityGroups: [][]string{}}, want: "canonical decimal"}, + {name: "noncanonical maximum", selections: SelectionRequestV1{Options: []string{}, Minimum: "0", Maximum: "01", Defaults: []string{}, CompatibilityGroups: [][]string{}}, want: "canonical decimal"}, + {name: "invalid option", selections: SelectionRequestV1{Options: []string{"Chromium"}, Minimum: "0", Maximum: "1", Defaults: []string{}, CompatibilityGroups: [][]string{{"Chromium"}}}, want: "canonical identifiers"}, + {name: "minimum too large", selections: SelectionRequestV1{Options: []string{"chromium"}, Minimum: "2", Maximum: "1", Defaults: []string{}, CompatibilityGroups: [][]string{{"chromium"}}}, want: "cardinality"}, + {name: "uncovered option", selections: SelectionRequestV1{Options: []string{"chromium", "webkit"}, Minimum: "0", Maximum: "2", Defaults: []string{}, CompatibilityGroups: [][]string{{"chromium"}}}, want: "do not cover"}, + {name: "nonmaximal groups", selections: SelectionRequestV1{Options: []string{"chromium", "webkit"}, Minimum: "0", Maximum: "2", Defaults: []string{}, CompatibilityGroups: [][]string{{"chromium"}, {"chromium", "webkit"}}}, want: "must be maximal"}, + {name: "unknown default", selections: SelectionRequestV1{Options: []string{"chromium"}, Minimum: "0", Maximum: "1", Defaults: []string{"webkit"}, CompatibilityGroups: [][]string{{"chromium"}}}, want: "not a declared option"}, + {name: "insufficient defaults", selections: SelectionRequestV1{Options: []string{"chromium", "webkit"}, Minimum: "2", Maximum: "2", Defaults: []string{"chromium"}, CompatibilityGroups: [][]string{{"chromium", "webkit"}}}, want: "do not satisfy"}, + {name: "incompatible defaults", selections: SelectionRequestV1{Options: []string{"chromium", "webkit"}, Minimum: "1", Maximum: "2", Defaults: []string{"chromium", "webkit"}, CompatibilityGroups: [][]string{{"chromium"}, {"webkit"}}}, want: "do not satisfy"}, + {name: "infeasible minimum", selections: SelectionRequestV1{Options: []string{"chromium", "webkit"}, Minimum: "2", Maximum: "2", Defaults: []string{}, CompatibilityGroups: [][]string{{"chromium"}, {"webkit"}}}, want: "cannot be satisfied"}, + } { + t.Run("selections "+test.name, func(t *testing.T) { + err := validateSelectionRequestV1(test.selections) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want substring %q", err, test.want) + } + }) + } +} + +func TestValidateRuntimeV1RejectsInconsistentContracts(t *testing.T) { + valid := RecordRuntimeV1{ + InstallRoot: "/opt/demo", Environment: []RecordEnvironmentVariableV1{{Name: "DEMO_HOME", Value: "/opt/demo"}}, + } + if err := validateRuntimeV1([]string{"build", "runtime"}, &valid); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + contexts []string + mutate func(*RecordRuntimeV1) + want string + }{ + {name: "missing runtime context", contexts: []string{"build"}, want: "inconsistent with contexts"}, + {name: "relative root", contexts: []string{"runtime"}, mutate: func(value *RecordRuntimeV1) { value.InstallRoot = "opt/demo" }, want: "inconsistent with contexts"}, + {name: "nil environment", contexts: []string{"runtime"}, mutate: func(value *RecordRuntimeV1) { value.Environment = nil }, want: "bounded array"}, + {name: "invalid environment name", contexts: []string{"runtime"}, mutate: func(value *RecordRuntimeV1) { value.Environment[0].Name = "demo_home" }, want: "unique and sorted"}, + {name: "environment NUL", contexts: []string{"runtime"}, mutate: func(value *RecordRuntimeV1) { value.Environment[0].Value = "bad\x00value" }, want: "unique and sorted"}, + } { + t.Run(test.name, func(t *testing.T) { + value := valid + value.Environment = append([]RecordEnvironmentVariableV1{}, valid.Environment...) + if test.mutate != nil { + test.mutate(&value) + } + err := validateRuntimeV1(test.contexts, &value) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want substring %q", err, test.want) + } + }) + } + if err := validateRuntimeV1([]string{"runtime"}, nil); err == nil || !strings.Contains(err.Error(), "requires a runtime contract") { + t.Fatalf("missing runtime error = %v", err) + } +} + +func TestValidateProbeV1RequiresOfflineCanonicalExecution(t *testing.T) { + valid := RecordProbeV1{Path: "/opt/demo/bin/demo", Args: []string{"--version"}, Network: "none"} + if err := validateProbeV1(valid); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + mutate func(*RecordProbeV1) + want string + }{ + {name: "relative path", mutate: func(value *RecordProbeV1) { value.Path = "demo" }, want: "absolute path"}, + {name: "nil args", mutate: func(value *RecordProbeV1) { value.Args = nil }, want: "argument array"}, + {name: "network", mutate: func(value *RecordProbeV1) { value.Network = "host" }, want: "network=none"}, + {name: "NUL", mutate: func(value *RecordProbeV1) { value.Args = []string{"bad\x00arg"} }, want: "control characters"}, + } { + t.Run(test.name, func(t *testing.T) { + value := valid + value.Args = append([]string{}, valid.Args...) + test.mutate(&value) + err := validateProbeV1(value) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want substring %q", err, test.want) + } + }) + } +} + +func TestPayloadIDsEncodeSelectionAndPlatform(t *testing.T) { + selected := *(validRecordValuesV1()[6].(*PayloadRecordV1)) + if err := validateLoadedRecordV1(loadedRecordV1{ID: selected.ID, Schema: selected.Schema, Value: &selected}); err != nil { + t.Fatal(err) + } + unconditional := selected + unconditional.Selection = "" + unconditional.ID = "tool:demo/releases/1.2.3/payloads/chromium-linux-amd64" + if err := validateLoadedRecordV1(loadedRecordV1{ID: unconditional.ID, Schema: unconditional.Schema, Value: &unconditional}); err != nil { + t.Fatal(err) + } +} + +func TestBindingArtifactAcceptsUniversalAndCompressedWheelTagsV1(t *testing.T) { + value := *(validRecordValuesV1()[5].(*BindingArtifactRecordV1)) + value.Filename = "demo-1.2.3-py2.py3-none-any.whl" + value.Tags = []string{"py2-none-any", "py3-none-any"} + if err := validateLoadedRecordV1(loadedRecordV1{ID: value.ID, Schema: value.Schema, Value: &value}); err != nil { + t.Fatal(err) + } +} + +func TestBindingArtifactPlatformTagPoliciesFollowTheirPEPsV1(t *testing.T) { + arm64 := func(tag string) *BindingArtifactRecordV1 { + value := *(validRecordValuesV1()[5].(*BindingArtifactRecordV1)) + value.ID = "tool:demo/releases/1.2.3/bindings/python/artifacts/linux-arm64" + value.Platform = "linux/arm64" + value.Filename = "demo-1.2.3-py3-none-" + tag + ".whl" + value.Tags = []string{"py3-none-" + tag} + return &value + } + // PEP 513 and PEP 571 define manylinux1 and manylinux2010 for x86_64 and + // i686 only. PEP 599 adds aarch64 with manylinux2014, and PEP 600 covers it + // with the versioned manylinux_x_y policies. + for _, tag := range []string{"manylinux1_aarch64", "manylinux2010_aarch64"} { + value := arm64(tag) + if err := validateLoadedRecordV1(loadedRecordV1{ID: value.ID, Schema: value.Schema, Value: value}); err == nil { + t.Errorf("%s accepted on linux/arm64", tag) + } + } + for _, tag := range []string{"manylinux2014_aarch64", "manylinux_2_28_aarch64", "linux_aarch64"} { + value := arm64(tag) + if err := validateLoadedRecordV1(loadedRecordV1{ID: value.ID, Schema: value.Schema, Value: value}); err != nil { + t.Errorf("%s rejected on linux/arm64: %v", tag, err) + } + } +} + +func TestTypedParameterSchemasAndTargetConstraintsV1(t *testing.T) { + contract := *(validRecordValuesV1()[2].(*ReleaseContractV1)) + colorDefault := "blue" + workerDefault := "2" + contract.Parameters = []ParameterSchemaV1{ + {Name: "color", Type: "enum", Required: false, Default: &colorDefault, Values: []string{"blue", "green"}, Minimum: "", Maximum: ""}, + {Name: "debug", Type: "boolean", Required: false, Default: nil, Values: []string{}, Minimum: "", Maximum: ""}, + {Name: "workers", Type: "integer", Required: true, Default: &workerDefault, Values: []string{}, Minimum: "-2", Maximum: "4"}, + } + if err := validateLoadedRecordV1(loadedRecordV1{ID: contract.ID, Schema: contract.Schema, Value: &contract}); err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(&contract) + if err != nil { + t.Fatal(err) + } + if _, err := decodeRecordV1("contract.json", payload); err != nil { + t.Fatalf("decode contract with explicit null default: %v", err) + } + withoutDefault := bytes.Replace(payload, []byte(`,"default":null`), nil, 1) + if _, err := decodeRecordV1("contract.json", withoutDefault); err == nil || !strings.Contains(err.Error(), `required field "default" is missing`) { + t.Fatalf("missing nullable default error = %v", err) + } + + target := *(validRecordValuesV1()[3].(*TargetRecordV1)) + target.Parameters = []TargetParameterConstraintV1{ + {Name: "color", Values: []string{"blue"}, Minimum: "", Maximum: ""}, + {Name: "workers", Values: []string{}, Minimum: "0", Maximum: "2"}, + } + if err := validateLoadedRecordV1(loadedRecordV1{ID: target.ID, Schema: target.Schema, Value: &target}); err != nil { + t.Fatal(err) + } + + contract.Parameters[2].Default = func() *string { value := "5"; return &value }() + if err := validateLoadedRecordV1(loadedRecordV1{ID: contract.ID, Schema: contract.Schema, Value: &contract}); err == nil || !strings.Contains(err.Error(), "outside its declared domain") { + t.Fatalf("out-of-range parameter default error = %v", err) + } + target.Parameters[1].Minimum = "02" + if err := validateLoadedRecordV1(loadedRecordV1{ID: target.ID, Schema: target.Schema, Value: &target}); err == nil || !strings.Contains(err.Error(), "canonical bounded integer") { + t.Fatalf("noncanonical target parameter bound error = %v", err) + } + contract.Parameters[2].Default = &workerDefault + contract.Parameters[2].Minimum = "0" + contract.Parameters[2].Maximum = "1024" + if err := validateLoadedRecordV1(loadedRecordV1{ID: contract.ID, Schema: contract.Schema, Value: &contract}); err == nil || !strings.Contains(err.Error(), "enumerable domain limit") { + t.Fatalf("oversized parameter domain error = %v", err) + } + contract.Parameters = make([]ParameterSchemaV1, 11) + for index := range contract.Parameters { + contract.Parameters[index] = ParameterSchemaV1{Name: fmt.Sprintf("p%02d", index), Type: "boolean", Required: true, Values: []string{}} + } + if err := validateLoadedRecordV1(loadedRecordV1{ID: contract.ID, Schema: contract.Schema, Value: &contract}); err == nil || !strings.Contains(err.Error(), "Cartesian coverage") { + t.Fatalf("Cartesian parameter coverage error = %v", err) + } +} + +func TestRecordReferencesIDsAndQuantitiesAreCanonical(t *testing.T) { + for _, test := range []struct { + name string + run func() error + want string + }{ + {name: "empty tool name", run: func() error { return validateRecordIDV1("tool:") }, want: "invalid tool name"}, + {name: "uppercase tool name", run: func() error { return validateRecordIDV1("tool:Demo") }, want: "invalid tool name"}, + {name: "empty segment", run: func() error { return validateRecordIDV1("tool:demo//contract") }, want: "invalid path segment"}, + {name: "bad digest", run: func() error { + return validateRecordReferenceV1(RecordReferenceV1{ID: "tool:demo", Digest: "sha256:ABC"}) + }, want: "digest"}, + {name: "leading zero", run: func() error { return validateCanonicalDecimalV1("size", "01", true) }, want: "canonical decimal"}, + {name: "zero", run: func() error { return validateCanonicalDecimalV1("size", "0", true) }, want: "positive decimal"}, + {name: "overflow", run: func() error { return validateCanonicalDecimalV1("size", "9223372036854775808", true) }, want: "positive decimal"}, + } { + t.Run(test.name, func(t *testing.T) { + err := test.run() + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want substring %q", err, test.want) + } + }) + } +} + +func TestRecordReferencesRequireCanonicalReleaseNamespaces(t *testing.T) { + valid := recordTestReference("tool:demo/releases/1%212/contract") + if err := validateRecordReferenceV1(valid); err != nil { + t.Fatalf("valid encoded release reference: %v", err) + } + for _, id := range []string{ + "tool:demo/releases/%31/revisions/1/manifest", + "tool:demo/releases/1/contract%21", + "tool:demo/other/record", + } { + reference := recordTestReference(id) + if err := validateRecordReferenceV1(reference); err == nil { + t.Fatalf("noncanonical record reference %q was accepted", id) + } + } +}