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 4af717e4..cea997cc 100644 --- a/internal/toolcatalog/records_decode.go +++ b/internal/toolcatalog/records_decode.go @@ -87,6 +87,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) @@ -607,6 +610,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..db78b7f1 --- /dev/null +++ b/internal/toolcatalog/records_validate.go @@ -0,0 +1,1281 @@ +package toolcatalog + +import ( + "bytes" + "fmt" + "path" + "sort" + "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 := validateProfileReferenceListV1("release validation profiles", value.ValidationProfiles, releasePrefix, false); 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 := validateBindingSetSchemaV1(value.Binding); err != nil { + return err + } + if err := validateSelectionSchemaV1(value.Selections); err != nil { + return err + } + if err := validateSortedUniqueStringsV1("compatibility constraints", value.CompatibilityConstraints, false); 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 := validateProfileReferenceListV1("target validation profiles", value.ValidationProfiles, releasePrefix, false); 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 payloads", binding.Payloads); err != nil { + return err + } + for _, reference := range binding.Payloads { + if err := validatePayloadReferenceV1("target binding payload", reference, releasePrefix); 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 := validateProfileReferenceListV1("target binding validation profiles", binding.ValidationProfiles, releasePrefix, 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.Dimension) || !validRecordIdentifierV1(selection.Value) || index > 0 && compareTargetSelectionV1(value.Selections[index-1], selection) >= 0 { + 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 := validateProfileReferenceListV1("target selection validation profiles", selection.ValidationProfiles, releasePrefix, true); err != nil { + return err + } + if len(selection.Payloads)+len(selection.PackageSets)+len(selection.Exports)+len(selection.ValidationProfiles) == 0 { + return fmt.Errorf("target selection %q/%q must contribute at least one record, export, or validation profile", selection.Dimension, selection.Value) + } + } + if err := validateExportsV1("target exports", value.Exports); err != nil { + return err + } + return nil + case *BindingContractV1: + if record.Schema != BindingContractSchemaV1 || value.Schema != BindingContractSchemaV1 || value.ID != record.ID || !validRecordIdentifierV1(value.Name) || !validPackageNameV1(value.Package) { + return fmt.Errorf("binding contract is incomplete") + } + if err := validateBindingContractIDV1(value.ID, value.Name); err != nil { + return err + } + if !validRecordIdentifierV1(value.CLI.Name) || validateAbsoluteRecordPathV1(value.CLI.Path) != nil { + return fmt.Errorf("binding CLI must use a canonical name and absolute path") + } + 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 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 := requireNonemptySortedStringsV1("payload executables", value.Executables); err != nil { + return err + } + for _, executable := range value.Executables { + if err := validateRecordPathV1(executable, false); err != nil { + return fmt.Errorf("payload executable: %w", err) + } + if value.ArchiveRoot != "." && executable != value.ArchiveRoot && !strings.HasPrefix(executable, value.ArchiveRoot+"/") { + return fmt.Errorf("payload executable %q is outside archive root %q", executable, value.ArchiveRoot) + } + } + if path.Dir(value.InstallDirectory) != "." { + return fmt.Errorf("payload paths are inconsistent") + } + if value.Kind == "raw-executable" && (value.Entries != "1" || value.UnpackedSize != value.Size || value.ArchiveRoot != "." || len(value.Executables) != 1) { + return fmt.Errorf("raw executable payload inventory is inconsistent") + } + return nil + case *ArtifactSourceRecordV1: + if record.Schema != ArtifactSourceRecordSchemaV1 || value.Schema != ArtifactSourceRecordSchemaV1 || value.ID != record.ID { + return fmt.Errorf("artifact source identity is inconsistent") + } + 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 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 err := validateSortedUniqueStringsV1("integration fixture bindings", value.Bindings, false); err != nil { + return err + } + for _, binding := range value.Bindings { + if !validRecordIdentifierV1(binding) { + return fmt.Errorf("integration fixture bindings must be canonical identifiers") + } + } + if value.Selections == nil || len(value.Selections) > maxDefinitionReferences { + return fmt.Errorf("integration fixture selections must use a bounded map") + } + dimensions := make([]string, 0, len(value.Selections)) + for dimension := range value.Selections { + dimensions = append(dimensions, dimension) + } + sort.Strings(dimensions) + for _, dimension := range dimensions { + if !validRecordIdentifierV1(dimension) { + return fmt.Errorf("integration fixture selection dimension %q is invalid", dimension) + } + selections := value.Selections[dimension] + if err := requireNonemptySortedStringsV1("integration fixture selection values", selections); err != nil { + return err + } + for _, selection := range selections { + if !validRecordIdentifierV1(selection) { + return fmt.Errorf("integration fixture selections must be canonical identifiers") + } + } + } + return validateProfileReferenceListV1("integration fixture validation profiles", value.ValidationProfiles, strings.Join(segments[:3], "/"), false) + 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) + } + expectedPrefix := fmt.Sprintf("tool:%s/releases/%s/validation/profiles/", value.Tool, versionSegment) + if !strings.HasPrefix(value.ID, expectedPrefix) || !validRecordIdentifierV1(strings.TrimPrefix(value.ID, expectedPrefix)) { + return fmt.Errorf("validation profile ID must use a canonical name beneath %q", expectedPrefix) + } + if err := validateProbeListV1("validation profile probes", value.Probes, false); err != nil { + return err + } + 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 +} + +func validateProfileReferenceV1(field string, reference RecordReferenceV1, releasePrefix string) error { + segments, ok := referenceSegmentsUnderV1(reference, releasePrefix, 6) + if !ok || segments[3] != "validation" || segments[4] != "profiles" || !validRecordIdentifierV1(segments[5]) { + return fmt.Errorf("%s %q must name a validation profile under %q", field, reference.ID, releasePrefix+"/validation/profiles") + } + return nil +} + +func validateProfileReferenceListV1(field string, references []RecordReferenceV1, releasePrefix string, allowEmpty bool) error { + if err := validateReferenceListV1(field, references); err != nil { + return err + } + if !allowEmpty && len(references) == 0 { + return fmt.Errorf("%s must not be empty", field) + } + for _, reference := range references { + if err := validateProfileReferenceV1(field, reference, releasePrefix); err != nil { + return err + } + } + 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 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 && len(segments) != 6 || 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 len(segments) == 5 && segments[4] == expectedLeaf { + return nil + } + if len(segments) != 6 || !validRecordIdentifierV1(segments[4]) || segments[5] != expectedLeaf { + return fmt.Errorf("payload ID must end with /payloads//%s or /payloads/%s", expectedLeaf, 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 { + return fmt.Errorf("probe must use an absolute path and bounded argument array") + } + 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 validateBindingSetSchemaV1(binding BindingSetSchemaV1) error { + if err := validateSortedUniqueStringsV1("binding options", binding.Options, false); err != nil { + return err + } + for _, option := range binding.Options { + if !validRecordIdentifierV1(option) { + return fmt.Errorf("binding options must be canonical identifiers") + } + } + return nil +} + +func validateSelectionSchemaV1(selections SelectionSchemaV1) error { + if selections.Dimensions == nil || len(selections.Dimensions) > maxDefinitionReferences { + return fmt.Errorf("selection dimensions must use a bounded array") + } + dimensionOptions := make(map[string][]string, len(selections.Dimensions)) + for index, dimension := range selections.Dimensions { + if !validRecordIdentifierV1(dimension.Name) || index > 0 && selections.Dimensions[index-1].Name >= dimension.Name { + return fmt.Errorf("selection dimensions must have unique sorted canonical names") + } + if err := requireNonemptySortedStringsV1("selection dimension options", dimension.Options); err != nil { + return err + } + for _, option := range dimension.Options { + if !validRecordIdentifierV1(option) { + return fmt.Errorf("selection dimension %q options must be canonical identifiers", dimension.Name) + } + } + dimensionOptions[dimension.Name] = dimension.Options + } + if selections.Combinations == nil || len(selections.Combinations) > maxDefinitionValidationCases { + return fmt.Errorf("selection combinations must use at most %d entries", maxDefinitionValidationCases) + } + if len(selections.Dimensions) == 0 && len(selections.Combinations) != 0 || len(selections.Dimensions) != 0 && len(selections.Combinations) == 0 { + return fmt.Errorf("selection dimensions and combinations must either both be empty or both be nonempty") + } + var previousEncoded []byte + for index, combination := range selections.Combinations { + if combination == nil { + return fmt.Errorf("selection combination %d must be a dimension-keyed map", index) + } + dimensionNames := make([]string, 0, len(combination)) + for dimensionName := range combination { + dimensionNames = append(dimensionNames, dimensionName) + } + sort.Strings(dimensionNames) + for _, dimensionName := range dimensionNames { + options, ok := dimensionOptions[dimensionName] + if !ok { + return fmt.Errorf("selection combination dimension %q is not declared", dimensionName) + } + values := combination[dimensionName] + if err := requireNonemptySortedStringsV1("selection combination values", values); err != nil { + return err + } + for _, value := range values { + if !containsRecordValueV1(options, value) { + return fmt.Errorf("selection combination value %q is not advertised for dimension %q", value, dimensionName) + } + } + } + encoded, err := canonical.Marshal(combination) + if err != nil { + return fmt.Errorf("encode selection combination %d: %w", index, err) + } + if index > 0 && bytes.Compare(previousEncoded, encoded) >= 0 { + return fmt.Errorf("selection combinations must be unique and sorted") + } + previousEncoded = encoded + } + return nil +} + +func compareTargetSelectionV1(left TargetSelectionV1, right TargetSelectionV1) int { + if left.Dimension < right.Dimension { + return -1 + } + if left.Dimension > right.Dimension { + return 1 + } + return strings.Compare(left.Value, right.Value) +} + +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 +} diff --git a/internal/toolcatalog/records_validate_test.go b/internal/toolcatalog/records_validate_test.go new file mode 100644 index 00000000..89725d7d --- /dev/null +++ b/internal/toolcatalog/records_validate_test.go @@ -0,0 +1,1014 @@ +package toolcatalog + +import ( + "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{{Dimension: "browser", Value: "chromium", Payloads: []RecordReferenceV1{}, PackageSets: []RecordReferenceV1{}, Exports: []ToolExportV1{}, ValidationProfiles: []RecordReferenceV1{}}} + 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.Executables = []string{"../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 name", value: func() any { + value := *(values[10].(*ValidationProfileRecordV1)) + value.ID = "tool:demo/releases/1.2.3/validation/profiles/Bad" + return &value + }(), want: "canonical name"}, + {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 = map[string][]string{"browser": {"Chromium"}} }}, + {name: "fixture selection contains a space", index: 9, wantSub: "canonical identifiers", + mutate: func(v any) { + v.(*IntegrationFixtureRecordV1).Selections = map[string][]string{"browser": {"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 TestRebuiltRecordShapesAreValidatedV1(t *testing.T) { + const release = "tool:demo/releases/1.2.3" + validPayload := recordTestReference(release + "/payloads/demo-linux-amd64") + validProfile := recordTestReference(release + "/validation/profiles/default") + for _, testCase := range []struct { + name string + index int + mutate func(any) + wantSub string + }{ + {name: "manifest profiles empty", index: 1, wantSub: "must not be empty", mutate: func(value any) { + value.(*ReleaseManifestV1).ValidationProfiles = []RecordReferenceV1{} + }}, + {name: "contract binding option", index: 2, wantSub: "canonical identifiers", mutate: func(value any) { + value.(*ReleaseContractV1).Binding.Options = []string{"Python"} + }}, + {name: "contract selection tuple", index: 2, wantSub: "not advertised", mutate: func(value any) { + value.(*ReleaseContractV1).Selections = SelectionSchemaV1{ + Dimensions: []SelectionDimensionV1{{Name: "browser", Options: []string{"chromium"}}}, + Combinations: []SelectionCombinationV1{{"browser": {"webkit"}}}, + } + }}, + {name: "contract compatibility constraints", index: 2, wantSub: "unique sorted", mutate: func(value any) { + value.(*ReleaseContractV1).CompatibilityConstraints = []string{"z", "a"} + }}, + {name: "target profile namespace", index: 3, wantSub: "validation profile", mutate: func(value any) { + value.(*TargetRecordV1).ValidationProfiles = []RecordReferenceV1{recordTestReference("tool:other/releases/1.2.3/validation/profiles/default")} + }}, + {name: "target binding payload namespace", index: 3, wantSub: "payload record", mutate: func(value any) { + binding := targetBindingWithArtifactV1(release + "/bindings/python/artifacts/linux-amd64")[0] + binding.Payloads = []RecordReferenceV1{recordTestReference("tool:other/releases/1.2.3/payloads/demo-linux-amd64")} + value.(*TargetRecordV1).Bindings = []TargetBindingV1{binding} + }}, + {name: "target binding profile namespace", index: 3, wantSub: "validation profile", mutate: func(value any) { + binding := targetBindingWithArtifactV1(release + "/bindings/python/artifacts/linux-amd64")[0] + binding.ValidationProfiles = []RecordReferenceV1{recordTestReference(release + "/validation/profiles/Bad")} + value.(*TargetRecordV1).Bindings = []TargetBindingV1{binding} + }}, + {name: "target selections unsorted", index: 3, wantSub: "unique, sorted", mutate: func(value any) { + value.(*TargetRecordV1).Selections = []TargetSelectionV1{ + {Dimension: "browser", Value: "webkit", Payloads: []RecordReferenceV1{validPayload}, PackageSets: []RecordReferenceV1{}, Exports: []ToolExportV1{}, ValidationProfiles: []RecordReferenceV1{}}, + {Dimension: "browser", Value: "chromium", Payloads: []RecordReferenceV1{validPayload}, PackageSets: []RecordReferenceV1{}, Exports: []ToolExportV1{}, ValidationProfiles: []RecordReferenceV1{}}, + } + }}, + {name: "target selection profile namespace", index: 3, wantSub: "validation profile", mutate: func(value any) { + value.(*TargetRecordV1).Selections = []TargetSelectionV1{{ + Dimension: "browser", Value: "chromium", Payloads: []RecordReferenceV1{validPayload}, PackageSets: []RecordReferenceV1{}, Exports: []ToolExportV1{}, + ValidationProfiles: []RecordReferenceV1{recordTestReference(release + "/validation/profiles/Bad")}, + }} + }}, + {name: "binding CLI", index: 4, wantSub: "absolute path", mutate: func(value any) { + value.(*BindingContractV1).CLI.Path = "bin/demo" + }}, + {name: "payload executable outside root", index: 6, wantSub: "outside archive root", mutate: func(value any) { + value.(*PayloadRecordV1).Executables = []string{"other/demo"} + }}, + {name: "fixture binding", index: 9, wantSub: "canonical identifiers", mutate: func(value any) { + value.(*IntegrationFixtureRecordV1).Bindings = []string{"Python"} + }}, + {name: "fixture dimension", index: 9, wantSub: "dimension", mutate: func(value any) { + value.(*IntegrationFixtureRecordV1).Selections = map[string][]string{"Browser": {"chromium"}} + }}, + {name: "fixture selection order", index: 9, wantSub: "unique sorted", mutate: func(value any) { + value.(*IntegrationFixtureRecordV1).Selections = map[string][]string{"browser": {"webkit", "chromium"}} + }}, + {name: "fixture profile namespace", index: 9, wantSub: "validation profile", mutate: func(value any) { + value.(*IntegrationFixtureRecordV1).ValidationProfiles = []RecordReferenceV1{recordTestReference(release + "/validation/profiles/Bad")} + }}, + } { + t.Run(testCase.name, func(t *testing.T) { + value := validRecordValuesV1()[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) + } + }) + } + + profile := *(validRecordValuesV1()[10].(*ValidationProfileRecordV1)) + profile.ID = release + "/validation/profiles/smoke" + if err := validateLoadedRecordV1(loadedRecordV1{ID: profile.ID, Schema: profile.Schema, Value: &profile}); err != nil { + t.Fatalf("named validation profile rejected: %v", err) + } + if err := validateProfileReferenceListV1("profiles", []RecordReferenceV1{validProfile}, release, false); err != nil { + t.Fatalf("valid profile list rejected: %v", err) + } +} + +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)}, + Payloads: []RecordReferenceV1{}, + PackageSets: []RecordReferenceV1{}, + Exports: []ToolExportV1{}, + ValidationProfiles: []RecordReferenceV1{}, + }} +} + +// 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.ValidationProfiles = []RecordReferenceV1{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.ValidationProfiles = []RecordReferenceV1{recordTestReference(release + "/validation/profiles/Bad")} + 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 = map[string][]string{"browser": 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.ValidationProfiles = append([]RecordReferenceV1{}, value.ValidationProfiles...) + value.ValidationProfiles[0].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 TestValidateBindingAndSelectionSchemasV1(t *testing.T) { + if err := validateBindingSetSchemaV1(BindingSetSchemaV1{Options: []string{"node", "python"}}); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + binding BindingSetSchemaV1 + want string + }{ + {name: "nil options", binding: BindingSetSchemaV1{Options: nil}, want: "must use an array"}, + {name: "invalid option", binding: BindingSetSchemaV1{Options: []string{"Python"}}, want: "canonical identifiers"}, + {name: "unsorted options", binding: BindingSetSchemaV1{Options: []string{"python", "node"}}, want: "unique sorted"}, + } { + t.Run("binding "+test.name, func(t *testing.T) { + err := validateBindingSetSchemaV1(test.binding) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want substring %q", err, test.want) + } + }) + } + + validSelections := SelectionSchemaV1{ + Dimensions: []SelectionDimensionV1{{Name: "browser", Options: []string{"chromium", "webkit"}}}, + Combinations: []SelectionCombinationV1{ + {"browser": {"chromium"}}, + {"browser": {"webkit"}}, + }, + } + if err := validateSelectionSchemaV1(validSelections); err != nil { + t.Fatal(err) + } + optionalSelection := SelectionSchemaV1{ + Dimensions: []SelectionDimensionV1{ + {Name: "browser", Options: []string{"chromium"}}, + {Name: "mode", Options: []string{"headless"}}, + }, + Combinations: []SelectionCombinationV1{{"browser": {"chromium"}}}, + } + if err := validateSelectionSchemaV1(optionalSelection); err != nil { + t.Fatalf("omitted optional dimension rejected: %v", err) + } + canonicalByteOrder := SelectionSchemaV1{ + Dimensions: []SelectionDimensionV1{{Name: "browser", Options: []string{"chromium", "webkit"}}}, + Combinations: []SelectionCombinationV1{ + {"browser": {"chromium", "webkit"}}, + {"browser": {"chromium"}}, + }, + } + if err := validateSelectionSchemaV1(canonicalByteOrder); err != nil { + t.Fatalf("canonical encoded-byte order rejected: %v", err) + } + tooManyCombinations := make([]SelectionCombinationV1, maxDefinitionValidationCases+1) + for index := range tooManyCombinations { + tooManyCombinations[index] = SelectionCombinationV1{"browser": {"chromium"}} + } + for _, test := range []struct { + name string + selections SelectionSchemaV1 + want string + }{ + {name: "nil dimensions", selections: SelectionSchemaV1{Dimensions: nil, Combinations: []SelectionCombinationV1{}}, want: "bounded array"}, + {name: "invalid option", selections: SelectionSchemaV1{Dimensions: []SelectionDimensionV1{{Name: "browser", Options: []string{"Chromium"}}}, Combinations: []SelectionCombinationV1{{"browser": {"Chromium"}}}}, want: "canonical identifiers"}, + {name: "missing combinations", selections: SelectionSchemaV1{Dimensions: validSelections.Dimensions, Combinations: []SelectionCombinationV1{}}, want: "both be empty"}, + {name: "nil combination", selections: SelectionSchemaV1{Dimensions: validSelections.Dimensions, Combinations: []SelectionCombinationV1{nil}}, want: "dimension-keyed map"}, + {name: "undeclared dimension", selections: SelectionSchemaV1{Dimensions: validSelections.Dimensions, Combinations: []SelectionCombinationV1{{"mode": {"headless"}}}}, want: "is not declared"}, + {name: "empty value set", selections: SelectionSchemaV1{Dimensions: validSelections.Dimensions, Combinations: []SelectionCombinationV1{{"browser": {}}}}, want: "must not be empty"}, + {name: "undeclared value", selections: SelectionSchemaV1{Dimensions: validSelections.Dimensions, Combinations: []SelectionCombinationV1{{"browser": {"firefox"}}}}, want: "not advertised"}, + {name: "duplicate combinations", selections: SelectionSchemaV1{Dimensions: validSelections.Dimensions, Combinations: []SelectionCombinationV1{{"browser": {"chromium"}}, {"browser": {"chromium"}}}}, want: "unique and sorted"}, + {name: "semantic rather than encoded order", selections: SelectionSchemaV1{Dimensions: canonicalByteOrder.Dimensions, Combinations: []SelectionCombinationV1{{"browser": {"chromium"}}, {"browser": {"chromium", "webkit"}}}}, want: "unique and sorted"}, + {name: "too many combinations", selections: SelectionSchemaV1{Dimensions: validSelections.Dimensions, Combinations: tooManyCombinations}, want: "at most"}, + } { + t.Run("selections "+test.name, func(t *testing.T) { + err := validateSelectionSchemaV1(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"}} + 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: "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.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 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) + } + } +}