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