From 89c8d6048e6a3524e37ec2cb0d6d0bec181b45d8 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Wed, 19 Aug 2026 01:28:22 +0800 Subject: [PATCH] Validate target composition and fixture coverage Add the composition layer above record-local validation: one target validated against its release contract, the support tuples that target advertises enumerated under a bound, and every tuple proven to have integration-fixture coverage with no unselected contribution leaking in. Fourteen functions move from the parked extraction source b39985d247e5 without semantic change, together with the supportTupleV1 type they enumerate. maxDefinitionValidationCases already exists from PTD-04, and PackageRootDistributionNameV1 resolves through PTD-01, so nothing else is new. Three truth fixes, each required by the normative design and each carrying negative coverage. The parked source disagrees with the design on all three. A binding advertises a set of interpreters, and the parked check passes as soon as one of them is satisfied by one artifact. Advertising 3.11 and 3.12 while shipping only a cp311 wheel therefore passed, leaving an advertised interpreter with nothing to install. Coverage is now checked across the whole selected artifact set, and validateTargetBindingsAgainstContractsV1 gives both the per-artifact and the set-level check a caller in this slice. Co-selectable payloads could collide. The parked tuple check gathers package sets and exports and never gathers payloads at all, so two payloads reachable in one support tuple could share a logical path or own overlapping install destinations and still pass. Design rule 10 makes both semantic keys and allows a shared unowned parent while forbidding overlapping owned trees, which is what recordPathOverlapsV1 implements. Probe identity is the sixth semantic key in that same rule and was likewise unenforced. Identical probes deduplicate; the same executable invoked with different arguments is now a conflict rather than two probes. The composition validators have no production caller yet. PTD-06 owns the release-graph walker that calls them, so they are exercised directly by tests here rather than through a graph. One observation recorded rather than fixed: validRecordValuesV1 is not reference-closed, because the sample target names an unconditional payload the shared set does not contain. Nothing before this slice resolved references, so nothing noticed. The composition test helper supplies the missing payload instead of changing a fixture four approved slices depend on. --- internal/toolcatalog/records_compose.go | 824 +++++++++++++ internal/toolcatalog/records_compose_test.go | 1107 ++++++++++++++++++ 2 files changed, 1931 insertions(+) create mode 100644 internal/toolcatalog/records_compose.go create mode 100644 internal/toolcatalog/records_compose_test.go diff --git a/internal/toolcatalog/records_compose.go b/internal/toolcatalog/records_compose.go new file mode 100644 index 00000000..77ef1349 --- /dev/null +++ b/internal/toolcatalog/records_compose.go @@ -0,0 +1,824 @@ +package toolcatalog + +import ( + "fmt" + "sort" + "strconv" + "strings" + + pep440 "github.com/aquasecurity/go-pep440-version" + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/canonical" + pythonprovider "github.com/omry/reploy/internal/providers/python" +) + +// Target composition and fixture coverage for the portable tool record model. +// Record-local validation lives in records_validate.go; this file validates one +// target against its release contract and proves every support tuple that +// target advertises is covered by an integration fixture. + +// supportTupleV1 is one exact combination a target advertises: a context, a +// binding, a normalized selection set, and normalized parameter values. +type supportTupleV1 struct { + Context string `json:"context"` + Binding string `json:"binding"` + Selections []string `json:"selections"` + Parameters []ParameterValueV1 `json:"parameters"` +} + +func resolvedRecordV1(records map[string]loadedRecordV1, reference RecordReferenceV1) (loadedRecordV1, error) { + record, exists := records[reference.ID] + if !exists || record.ID != reference.ID || record.Digest != reference.Digest { + return loadedRecordV1{}, fmt.Errorf("reference %q does not resolve to its exact record", reference.ID) + } + return record, nil +} + +func supportTupleKeyV1(tuple supportTupleV1) (string, error) { + payload, err := canonical.Marshal(tuple) + if err != nil { + return "", fmt.Errorf("support tuple canonical form: %w", err) + } + return string(payload), nil +} + +func normalizedFixtureTupleV1(contract *ReleaseContractV1, fixture *IntegrationFixtureRecordV1) supportTupleV1 { + binding := fixture.Binding + if binding == "" { + binding = contract.Binding.Default + } + selections := append([]string{}, fixture.Selections...) + if len(selections) == 0 && len(contract.Selections.Defaults) != 0 { + selections = append([]string{}, contract.Selections.Defaults...) + } + provided := make(map[string]string, len(fixture.Parameters)) + for _, parameter := range fixture.Parameters { + provided[parameter.Name] = parameter.Value + } + parameters := make([]ParameterValueV1, 0, len(contract.Parameters)) + for _, parameter := range contract.Parameters { + value, exists := provided[parameter.Name] + if !exists && parameter.Default != nil { + value, exists = *parameter.Default, true + } + if exists { + parameters = append(parameters, ParameterValueV1{Name: parameter.Name, Value: value}) + } + } + return supportTupleV1{Context: fixture.Context, Binding: binding, Selections: selections, Parameters: parameters} +} + +func targetParameterAllowsV1(constraints []TargetParameterConstraintV1, name string, value string) bool { + for _, constraint := range constraints { + if constraint.Name != name { + continue + } + if len(constraint.Values) != 0 { + return containsRecordValueV1(constraint.Values, value) + } + parsed, err := parseCanonicalIntegerV1("fixture parameter", value) + minimum, minimumErr := parseCanonicalIntegerV1("target parameter minimum", constraint.Minimum) + maximum, maximumErr := parseCanonicalIntegerV1("target parameter maximum", constraint.Maximum) + return err == nil && minimumErr == nil && maximumErr == nil && parsed >= minimum && parsed <= maximum + } + return true +} + +func targetParameterDomainV1(parameter ParameterSchemaV1, constraints []TargetParameterConstraintV1) ([]*string, error) { + values := make([]string, 0) + constrained := false + for _, constraint := range constraints { + if constraint.Name != parameter.Name { + continue + } + constrained = true + if len(constraint.Values) != 0 { + values = append(values, constraint.Values...) + } else { + minimum, _ := parseCanonicalIntegerV1("target parameter minimum", constraint.Minimum) + maximum, _ := parseCanonicalIntegerV1("target parameter maximum", constraint.Maximum) + for value := minimum; ; value++ { + values = append(values, strconv.FormatInt(value, 10)) + if value == maximum { + break + } + } + } + break + } + if !constrained { + switch parameter.Type { + case "boolean": + values = []string{"false", "true"} + case "enum": + values = append(values, parameter.Values...) + case "integer": + minimum, _ := parseCanonicalIntegerV1("parameter minimum", parameter.Minimum) + maximum, _ := parseCanonicalIntegerV1("parameter maximum", parameter.Maximum) + for value := minimum; ; value++ { + values = append(values, strconv.FormatInt(value, 10)) + if value == maximum { + break + } + } + default: + return nil, fmt.Errorf("parameter %q has an unsupported domain", parameter.Name) + } + } + result := make([]*string, 0, len(values)+1) + if !parameter.Required && parameter.Default == nil { + result = append(result, nil) + } + for _, value := range values { + value := value + result = append(result, &value) + } + return result, nil +} + +func targetParameterAssignmentsV1(parameters []ParameterSchemaV1, constraints []TargetParameterConstraintV1) ([][]ParameterValueV1, error) { + domains := make([][]*string, len(parameters)) + for index, parameter := range parameters { + values, err := targetParameterDomainV1(parameter, constraints) + if err != nil { + return nil, err + } + domains[index] = values + } + result := make([][]ParameterValueV1, 0) + current := make([]ParameterValueV1, 0, len(parameters)) + var enumerate func(int) error + enumerate = func(index int) error { + if index == len(parameters) { + if len(result) == maxDefinitionValidationCases { + return fmt.Errorf("parameter coverage exceeds the validation case limit") + } + result = append(result, append([]ParameterValueV1{}, current...)) + return nil + } + for _, value := range domains[index] { + if value == nil { + if err := enumerate(index + 1); err != nil { + return err + } + continue + } + current = append(current, ParameterValueV1{Name: parameters[index].Name, Value: *value}) + if err := enumerate(index + 1); err != nil { + return err + } + current = current[:len(current)-1] + } + return nil + } + if err := enumerate(0); err != nil { + return nil, err + } + return result, nil +} + +func validSelectionSetsForCoverageV1(request SelectionRequestV1) ([][]string, error) { + minimum, _ := strconv.ParseUint(request.Minimum, 10, 63) + maximum, _ := strconv.ParseUint(request.Maximum, 10, 63) + sets := make(map[string][]string) + add := func(value []string) error { + key := strings.Join(value, "\x00") + if _, exists := sets[key]; exists { + return nil + } + if len(sets) == maxDefinitionValidationCases { + return fmt.Errorf("selection-set coverage exceeds the validation case limit") + } + sets[key] = append([]string{}, value...) + return nil + } + // A request that omits selections normalizes to the contract defaults, so + // when defaults exist the empty set is not a reachable tuple: it is the + // defaults tuple under another name. Enumerating it anyway would demand a + // fixture that normalizedFixtureTupleV1 can never produce. + if minimum == 0 && len(request.Defaults) == 0 { + if err := add([]string{}); err != nil { + return nil, err + } + } + for _, group := range request.CompatibilityGroups { + lower := int(minimum) + if lower < 1 { + lower = 1 + } + upper := int(maximum) + if upper > len(group) { + upper = len(group) + } + for count := lower; count <= upper; count++ { + chosen := make([]string, 0, count) + var enumerate func(int) error + enumerate = func(start int) error { + if len(chosen) == count { + return add(chosen) + } + remaining := count - len(chosen) + for index := start; index <= len(group)-remaining; index++ { + chosen = append(chosen, group[index]) + if err := enumerate(index + 1); err != nil { + return err + } + chosen = chosen[:len(chosen)-1] + } + return nil + } + if err := enumerate(0); err != nil { + return nil, err + } + } + } + result := make([][]string, 0, len(sets)) + for _, value := range sets { + result = append(result, value) + } + sort.Slice(result, func(left int, right int) bool { return compareRecordStringSlicesV1(result[left], result[right]) < 0 }) + return result, nil +} + +func targetSupportTuplesV1(contract *ReleaseContractV1, target *TargetRecordV1) ([]supportTupleV1, error) { + // Enumerate what this target advertises, not what the contract declares: + // a target that supports one binding but not another advertises only the + // tuples it can actually satisfy. + bindings := make([]string, 0, len(target.Bindings)+1) + for _, binding := range target.Bindings { + bindings = append(bindings, binding.Name) + } + if !contract.Binding.Required && contract.Binding.Default == "" { + bindings = append([]string{""}, bindings...) + } + selections, err := validSelectionSetsForCoverageV1(targetAdvertisedSelectionsV1(contract, target)) + if err != nil { + return nil, err + } + parameters, err := targetParameterAssignmentsV1(contract.Parameters, target.Parameters) + if err != nil { + return nil, err + } + result := make([]supportTupleV1, 0) + for _, context := range contract.Contexts { + for _, binding := range bindings { + for _, selected := range selections { + for _, values := range parameters { + if len(result) == maxDefinitionValidationCases { + return nil, fmt.Errorf("target support tuple coverage exceeds the validation case limit") + } + result = append(result, supportTupleV1{ + Context: context, Binding: binding, + Selections: append([]string{}, selected...), Parameters: append([]ParameterValueV1{}, values...), + }) + } + } + } + } + return result, nil +} + +// targetAdvertisesBindingV1 reports whether the target maps the named binding. +func targetAdvertisesBindingV1(target *TargetRecordV1, name string) bool { + for _, binding := range target.Bindings { + if binding.Name == name { + return true + } + } + return false +} + +// targetAdvertisedSelectionsV1 narrows a contract's selection request to the +// symbols this target advertises, so coverage enumerates only satisfiable sets. +func targetAdvertisedSelectionsV1(contract *ReleaseContractV1, target *TargetRecordV1) SelectionRequestV1 { + advertised := make([]string, 0, len(target.Selections)) + for _, selection := range target.Selections { + advertised = append(advertised, selection.Name) + } + narrowed := contract.Selections + narrowed.Options = advertised + narrowed.Defaults = intersectRecordValuesV1(contract.Selections.Defaults, advertised) + groups := make([][]string, 0, len(contract.Selections.CompatibilityGroups)) + for _, group := range contract.Selections.CompatibilityGroups { + if narrowedGroup := intersectRecordValuesV1(group, advertised); len(narrowedGroup) != 0 { + groups = append(groups, narrowedGroup) + } + } + narrowed.CompatibilityGroups = groups + return narrowed +} + +func intersectRecordValuesV1(values []string, allowed []string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + if containsRecordValueV1(allowed, value) { + result = append(result, value) + } + } + return result +} + +func validateTargetAgainstContractV1(contract *ReleaseContractV1, target *TargetRecordV1) error { + // A target advertises the subset of contract symbols that this exact OS + // generation and architecture supports. Every advertised symbol has exactly + // one entry and an unadvertised symbol cannot have one, but a target is not + // required to advertise every option the contract declares: support is + // derived from valid target leaves rather than promised contract-wide. + for _, binding := range target.Bindings { + if !containsRecordValueV1(contract.Binding.Options, binding.Name) { + return fmt.Errorf("target binding mapping %q is not declared by the release contract", binding.Name) + } + } + if contract.Binding.Required && len(target.Bindings) == 0 { + return fmt.Errorf("release contract requires a binding but the target advertises none") + } + if contract.Binding.Default != "" && len(target.Bindings) != 0 && + !targetAdvertisesBindingV1(target, contract.Binding.Default) { + return fmt.Errorf("target does not advertise the contract default binding %q", contract.Binding.Default) + } + for _, selection := range target.Selections { + if !containsRecordValueV1(contract.Selections.Options, selection.Name) { + return fmt.Errorf("target selection mapping %q is not declared by the release contract", selection.Name) + } + } + minimumSelections, _ := strconv.ParseUint(contract.Selections.Minimum, 10, 63) + if uint64(len(target.Selections)) < minimumSelections { + return fmt.Errorf("release contract requires %d selections but the target advertises %d", + minimumSelections, len(target.Selections)) + } + // A request that omits selections normalizes to the contract defaults, so a + // target that does not advertise a default has no contribution mapping to + // traverse for the request every consumer makes by default. Narrowing + // coverage around the gap would hide it. + advertisedSelections := make([]string, 0, len(target.Selections)) + for _, selection := range target.Selections { + advertisedSelections = append(advertisedSelections, selection.Name) + } + for _, defaulted := range contract.Selections.Defaults { + if !containsRecordValueV1(advertisedSelections, defaulted) { + return fmt.Errorf("target does not advertise the contract default selection %q", defaulted) + } + } + + contractParameters := make(map[string]ParameterSchemaV1, len(contract.Parameters)) + for _, parameter := range contract.Parameters { + contractParameters[parameter.Name] = parameter + } + for _, constraint := range target.Parameters { + parameter, exists := contractParameters[constraint.Name] + if !exists { + return fmt.Errorf("target parameter constraint %q is not declared by the release contract", constraint.Name) + } + if len(constraint.Values) != 0 { + for _, value := range constraint.Values { + if !parameterValueInSchemaV1(value, parameter) { + return fmt.Errorf("target parameter constraint %q value %q is outside the contract domain", constraint.Name, value) + } + } + if parameter.Default != nil && !containsRecordValueV1(constraint.Values, *parameter.Default) { + return fmt.Errorf("target parameter constraint %q excludes the contract default", constraint.Name) + } + continue + } + if parameter.Type != "integer" { + return fmt.Errorf("target parameter constraint %q range is incompatible with contract type %q", constraint.Name, parameter.Type) + } + minimum, _ := parseCanonicalIntegerV1("target parameter minimum", constraint.Minimum) + maximum, _ := parseCanonicalIntegerV1("target parameter maximum", constraint.Maximum) + contractMinimum, _ := parseCanonicalIntegerV1("contract parameter minimum", parameter.Minimum) + contractMaximum, _ := parseCanonicalIntegerV1("contract parameter maximum", parameter.Maximum) + if minimum < contractMinimum || maximum > contractMaximum { + return fmt.Errorf("target parameter constraint %q range widens the contract domain", constraint.Name) + } + if parameter.Default != nil { + defaultValue, _ := parseCanonicalIntegerV1("contract parameter default", *parameter.Default) + if defaultValue < minimum || defaultValue > maximum { + return fmt.Errorf("target parameter constraint %q excludes the contract default", constraint.Name) + } + } + } + return nil +} + +func validateBindingArtifactAgainstContractV1(contract *BindingContractV1, artifact *BindingArtifactRecordV1) error { + parts := strings.Split(strings.TrimSuffix(artifact.Filename, ".whl"), "-") + if len(parts) != 5 && len(parts) != 6 { + return fmt.Errorf("wheel identity is invalid") + } + distribution := pythonprovider.NormalizeDistributionName(parts[0]) + if distribution != pythonprovider.NormalizeDistributionName(contract.Package) { + return fmt.Errorf("wheel distribution %q does not match contract package %q", distribution, contract.Package) + } + wheelVersion, err := pep440.Parse(parts[1]) + if err != nil { + return fmt.Errorf("wheel version is invalid") + } + requirementFound := false + for _, requirement := range contract.Requirements { + // Record-local validation already proved every contract requirement + // parses, so a failure here is a defect rather than a requirement to + // skip over. + requirementDistribution, err := pythonprovider.PackageRootDistributionNameV1(requirement) + if err != nil { + return fmt.Errorf("contract requirement %q: %w", requirement, err) + } + if requirementDistribution != distribution { + continue + } + requirementFound = true + // Extras cannot appear: PackageRootDistributionNameV1 rejects them, so + // the specifier set begins at the first comparison operator. + remainder := "" + for index, character := range requirement { + if character == '<' || character == '>' || character == '=' || character == '!' || character == '~' { + remainder = requirement[index:] + break + } + } + if remainder != "" { + specifiers, err := pep440.NewSpecifiers(remainder) + if err != nil || !specifiers.Check(wheelVersion) { + return fmt.Errorf("wheel version %q does not satisfy contract requirement %q", parts[1], requirement) + } + } + break + } + if !requirementFound { + return fmt.Errorf("contract package %q has no binding requirement", contract.Package) + } + pythonSpecifiers, err := pep440.NewSpecifiers(artifact.RequiresPython) + if err != nil { + return fmt.Errorf("requires_python is invalid") + } + // The binding contract publishes its constituent metadata to consumers, so + // an artifact must actually bundle what the contract advertises, at the + // version and path it advertises. + bundled := make(map[string]BundledComponentV1, len(artifact.BundledComponents)) + for _, component := range artifact.BundledComponents { + bundled[component.Name] = component + } + for _, declared := range contract.BundledComponents { + present, exists := bundled[declared.Name] + if !exists { + return fmt.Errorf("contract declares bundled component %q which the artifact does not bundle", declared.Name) + } + if present.Version != declared.Version { + return fmt.Errorf("bundled component %q is version %q but the contract advertises %q", + declared.Name, present.Version, declared.Version) + } + if present.Path != declared.Path { + return fmt.Errorf("bundled component %q is at %q but the contract advertises %q", + declared.Name, present.Path, declared.Path) + } + } + for _, version := range contract.SupportedPython { + parsed, err := pep440.Parse(version) + if err == nil && pythonSpecifiers.Check(parsed) { + return nil + } + } + return fmt.Errorf("requires_python %q excludes every contract interpreter", artifact.RequiresPython) +} + +// Validate every binding contribution a target advertises against the binding +// contract it names: each selected artifact must agree with the contract, and +// the selected set together must cover every interpreter the contract +// advertises. +func validateTargetBindingsAgainstContractsV1(records map[string]loadedRecordV1, target *TargetRecordV1) error { + for _, binding := range target.Bindings { + record, err := resolvedRecordV1(records, binding.Contract) + if err != nil { + return err + } + contract, ok := record.Value.(*BindingContractV1) + if !ok { + return fmt.Errorf("target binding %q contract %q resolves to a non-contract record", binding.Name, binding.Contract.ID) + } + artifacts := make([]*BindingArtifactRecordV1, 0, len(binding.Artifacts)) + for _, reference := range binding.Artifacts { + artifactRecord, err := resolvedRecordV1(records, reference) + if err != nil { + return err + } + artifact, ok := artifactRecord.Value.(*BindingArtifactRecordV1) + if !ok { + return fmt.Errorf("target binding %q artifact %q resolves to a non-artifact record", binding.Name, reference.ID) + } + if err := validateBindingArtifactAgainstContractV1(contract, artifact); err != nil { + return fmt.Errorf("target binding %q artifact %q: %w", binding.Name, reference.ID, err) + } + artifacts = append(artifacts, artifact) + } + if err := validateBindingInterpreterCoverageV1(contract, artifacts); err != nil { + return err + } + } + return nil +} + +// A binding advertises a set of interpreters, and the artifacts a target selects +// for that binding must cover every one of them. Checking each artifact against +// the contract in isolation only proves that artifact is usable by at least one +// advertised interpreter, which is satisfied by a single wheel while other +// advertised interpreters have nothing to install. +func validateBindingInterpreterCoverageV1(contract *BindingContractV1, artifacts []*BindingArtifactRecordV1) error { + for _, version := range contract.SupportedPython { + parsed, err := pep440.Parse(version) + if err != nil { + return fmt.Errorf("contract interpreter %q is invalid", version) + } + covered := false + for _, artifact := range artifacts { + specifiers, err := pep440.NewSpecifiers(artifact.RequiresPython) + if err != nil { + return fmt.Errorf("binding artifact %q requires_python is invalid", artifact.ID) + } + if specifiers.Check(parsed) { + covered = true + break + } + } + if !covered { + return fmt.Errorf("binding %q advertises interpreter %q but no selected artifact supports it", contract.Name, version) + } + } + return nil +} + +func validateFixtureAgainstTargetV1(contract *ReleaseContractV1, target *TargetRecordV1, fixture *IntegrationFixtureRecordV1) error { + if !containsRecordValueV1(contract.Contexts, fixture.Context) { + return fmt.Errorf("context %q is not declared by the release contract", fixture.Context) + } + binding := fixture.Binding + if binding == "" { + binding = contract.Binding.Default + } + if binding == "" && contract.Binding.Required { + return fmt.Errorf("required binding is missing") + } + if binding != "" { + if !containsRecordValueV1(contract.Binding.Options, binding) { + return fmt.Errorf("binding %q is not declared by the release contract", binding) + } + available := false + for _, candidate := range target.Bindings { + available = available || candidate.Name == binding + } + if !available { + return fmt.Errorf("binding %q is unavailable on the target", binding) + } + } + selections := fixture.Selections + if len(selections) == 0 && len(contract.Selections.Defaults) != 0 { + selections = contract.Selections.Defaults + } + minimum, _ := strconv.ParseUint(contract.Selections.Minimum, 10, 63) + maximum, _ := strconv.ParseUint(contract.Selections.Maximum, 10, 63) + if uint64(len(selections)) < minimum || uint64(len(selections)) > maximum || !selectionSetCompatibleV1(selections, contract.Selections.CompatibilityGroups) { + return fmt.Errorf("selections do not satisfy the release contract") + } + for _, selection := range selections { + if !containsRecordValueV1(contract.Selections.Options, selection) { + return fmt.Errorf("selection %q is not declared by the release contract", selection) + } + available := false + for _, candidate := range target.Selections { + available = available || candidate.Name == selection + } + if !available { + return fmt.Errorf("selection %q is unavailable on the target", selection) + } + } + contractParameters := make(map[string]ParameterSchemaV1, len(contract.Parameters)) + for _, parameter := range contract.Parameters { + contractParameters[parameter.Name] = parameter + } + seenParameters := make(map[string]struct{}, len(fixture.Parameters)) + for _, value := range fixture.Parameters { + parameter, exists := contractParameters[value.Name] + if !exists || !parameterValueInSchemaV1(value.Value, parameter) || !targetParameterAllowsV1(target.Parameters, value.Name, value.Value) { + return fmt.Errorf("parameter %q is outside the contract or target domain", value.Name) + } + seenParameters[value.Name] = struct{}{} + } + for _, parameter := range contract.Parameters { + if _, exists := seenParameters[parameter.Name]; !exists && parameter.Required && parameter.Default == nil { + return fmt.Errorf("required parameter %q is missing", parameter.Name) + } + } + return nil +} + +func validatePackageSetReferencesV1(records map[string]loadedRecordV1, references []RecordReferenceV1, target *TargetRecordV1) error { + for _, reference := range references { + record, err := resolvedRecordV1(records, reference) + if err != nil { + return fmt.Errorf("target %q package set: %w", target.ID, err) + } + packageSet, ok := record.Value.(*NativePackageSetV1) + if !ok || packageSet.Manager != target.Target.PackageManager { + return fmt.Errorf("target %q package set %q uses an incompatible package manager", target.ID, reference.ID) + } + } + return nil +} + +func validateTupleContributionsV1(records map[string]loadedRecordV1, contract *ReleaseContractV1, target *TargetRecordV1, tuple supportTupleV1) error { + packageReferences := append([]RecordReferenceV1{}, target.PackageSets...) + // Unconditional target payloads belong to no selection, so they must not + // declare one. Selection-scoped payloads must declare exactly the selection + // whose entry references them. + payloadReferences := make([]selectedPayloadReferenceV1, 0, len(target.Payloads)) + for _, reference := range target.Payloads { + payloadReferences = append(payloadReferences, selectedPayloadReferenceV1{Reference: reference}) + } + exports := append([]ToolExportV1{}, contract.Exports...) + exports = append(exports, target.Exports...) + probes := append([]RecordProbeV1{}, contract.Probes...) + probes = append(probes, target.Probes...) + if tuple.Binding != "" { + for _, binding := range target.Bindings { + if binding.Name == tuple.Binding { + packageReferences = append(packageReferences, binding.PackageSets...) + exports = append(exports, binding.Exports...) + probes = append(probes, binding.Probes...) + break + } + } + } + // Only the selected symbols contribute. An unselected selection's payloads, + // package sets, exports, and probes never enter this tuple. + for _, selected := range tuple.Selections { + for _, selection := range target.Selections { + if selection.Name == selected { + packageReferences = append(packageReferences, selection.PackageSets...) + for _, reference := range selection.Payloads { + payloadReferences = append(payloadReferences, selectedPayloadReferenceV1{ + Reference: reference, Selection: selection.Name}) + } + exports = append(exports, selection.Exports...) + probes = append(probes, selection.Probes...) + break + } + } + } + packages := make(map[string]string) + for _, reference := range packageReferences { + record, err := resolvedRecordV1(records, reference) + if err != nil { + return err + } + packageSet, ok := record.Value.(*NativePackageSetV1) + if !ok { + return fmt.Errorf("package set %q resolves to a non-package-set record", reference.ID) + } + for _, requirement := range packageSet.Requirements { + parsed, err := blueprint.ParseAPTPackageRequest(requirement) + if err != nil { + return err + } + if previous, exists := packages[parsed.Name]; exists && previous != requirement { + return fmt.Errorf("selected package sets conflict on package %q: %q and %q", parsed.Name, previous, requirement) + } + packages[parsed.Name] = requirement + } + } + exportPaths := make(map[string]string) + for _, exported := range exports { + if previous, exists := exportPaths[exported.Name]; exists && previous != exported.Path { + return fmt.Errorf("selected contributions conflict on export %q: %q and %q", exported.Name, previous, exported.Path) + } + exportPaths[exported.Name] = exported.Path + } + if err := validateTuplePayloadsV1(records, payloadReferences, target.Target.Platform); err != nil { + return err + } + // Probe identity is the complete canonical probe value, matching what + // record-local validation accepts. Byte-identical probes deduplicate; the + // same executable invoked with different arguments is two probes, not a + // conflict, so both run. + seenProbes := make(map[string]struct{}, len(probes)) + for _, probe := range probes { + key, err := canonical.Marshal(probe) + if err != nil { + return fmt.Errorf("probe canonical form: %w", err) + } + seenProbes[string(key)] = struct{}{} + } + return nil +} + +// selectedPayloadReferenceV1 pairs a payload reference with the selection whose +// contribution mapping supplied it. An empty selection means the target +// references the payload unconditionally. +type selectedPayloadReferenceV1 struct { + Reference RecordReferenceV1 + Selection string +} + +// Payloads reachable in one support tuple are installed together, so they must +// not claim the same logical artifact or overlap each other's owned directory +// trees. Sharing an unowned parent is allowed; owning a path inside another +// payload's tree is not, however well their package requirements agree. +func validateTuplePayloadsV1(records map[string]loadedRecordV1, references []selectedPayloadReferenceV1, platform string) error { + type owned struct { + id string + directory string + } + logicalPaths := make(map[string]string) + installed := make([]owned, 0, len(references)) + for _, entry := range references { + reference := entry.Reference + record, err := resolvedRecordV1(records, reference) + if err != nil { + return err + } + payload, ok := record.Value.(*PayloadRecordV1) + if !ok { + return fmt.Errorf("payload %q resolves to a non-payload record", reference.ID) + } + // A target leaf owns data specific to one architecture, so a payload it + // installs must be built for that architecture. Record-local validation + // only proves the payload agrees with its own ID. + if payload.Platform != platform { + return fmt.Errorf("payload %q is built for platform %q but the target is %q", + payload.ID, payload.Platform, platform) + } + // Ownership is declared, not inferred: the payload's own selection must + // be the selection entry that references it, and an unconditional + // reference must name a payload that belongs to no selection. + if payload.Selection != entry.Selection { + if entry.Selection == "" { + return fmt.Errorf("unconditional target payload %q belongs to selection %q", payload.ID, payload.Selection) + } + return fmt.Errorf("selection %q references payload %q, which belongs to selection %q", + entry.Selection, payload.ID, payload.Selection) + } + if previous, exists := logicalPaths[payload.LogicalPath]; exists && previous != payload.ID { + return fmt.Errorf("co-selectable payloads %q and %q share logical path %q", previous, payload.ID, payload.LogicalPath) + } + logicalPaths[payload.LogicalPath] = payload.ID + for _, other := range installed { + if other.id == payload.ID { + continue + } + if recordPathOverlapsV1(other.directory, payload.InstallDirectory) { + return fmt.Errorf("co-selectable payloads %q and %q overlap install destinations %q and %q", + other.id, payload.ID, other.directory, payload.InstallDirectory) + } + } + installed = append(installed, owned{id: payload.ID, directory: payload.InstallDirectory}) + } + return nil +} + +// Two owned trees overlap when they are equal or one contains the other. A +// shared prefix that is not itself a path segment boundary is not containment. +func recordPathOverlapsV1(left string, right string) bool { + return left == right || + strings.HasPrefix(right, left+"/") || + strings.HasPrefix(left, right+"/") +} + +func validateTargetFixtureCoverageV1(records map[string]loadedRecordV1, contract *ReleaseContractV1, target *TargetRecordV1, fixtures []*IntegrationFixtureRecordV1) error { + expected, err := targetSupportTuplesV1(contract, target) + if err != nil { + return err + } + expectedKeys := make(map[string]supportTupleV1, len(expected)) + for _, tuple := range expected { + key, err := supportTupleKeyV1(tuple) + if err != nil { + return err + } + if err := validateTupleContributionsV1(records, contract, target, tuple); err != nil { + return err + } + expectedKeys[key] = tuple + } + actualKeys := make(map[string]string, len(fixtures)) + for _, fixture := range fixtures { + tuple := normalizedFixtureTupleV1(contract, fixture) + key, err := supportTupleKeyV1(tuple) + if err != nil { + return err + } + if previous, exists := actualKeys[key]; exists { + return fmt.Errorf("integration fixtures %q and %q cover the same support tuple", previous, fixture.ID) + } + if _, expected := expectedKeys[key]; !expected { + return fmt.Errorf("integration fixture %q covers an unsupported tuple", fixture.ID) + } + actualKeys[key] = fixture.ID + } + for _, tuple := range expected { + key, err := supportTupleKeyV1(tuple) + if err != nil { + return err + } + if _, covered := actualKeys[key]; !covered { + return fmt.Errorf("integration fixtures do not cover support tuple context=%q binding=%q selections=%v parameters=%v", tuple.Context, tuple.Binding, tuple.Selections, tuple.Parameters) + } + } + return nil +} diff --git a/internal/toolcatalog/records_compose_test.go b/internal/toolcatalog/records_compose_test.go new file mode 100644 index 00000000..23fda6ed --- /dev/null +++ b/internal/toolcatalog/records_compose_test.go @@ -0,0 +1,1107 @@ +package toolcatalog + +import ( + "fmt" + "strings" + "testing" +) + +// composeTestRecordsV1 indexes the shared valid record values by ID so +// composition validators can resolve references exactly as the loader will. +// +// The shared set is not reference-closed on its own: the sample target names an +// unconditional payload that validRecordValuesV1 does not contain. Nothing +// before this slice resolved references, so nothing noticed. The missing +// payload is supplied here rather than by changing the shared fixture, which +// approved slices depend on. +func composeTestRecordsV1(extra ...any) map[string]loadedRecordV1 { + records := make(map[string]loadedRecordV1) + records["tool:demo/releases/1.2.3/payloads/demo-linux-amd64"] = loadedRecordV1{ + ID: "tool:demo/releases/1.2.3/payloads/demo-linux-amd64", + Schema: PayloadRecordSchemaV1, Digest: recordTestDigest, + Value: &PayloadRecordV1{Schema: PayloadRecordSchemaV1, + ID: "tool:demo/releases/1.2.3/payloads/demo-linux-amd64", + Name: "demo", Platform: "linux/amd64", + LogicalPath: "tools/demo/demo.tar.gz", InstallDirectory: "demo"}, + } + add := func(value any) { + id := recordIDV1(value) + records[id] = loadedRecordV1{ID: id, Schema: recordSchemaV1(value), Digest: recordTestDigest, Value: value} + } + for _, value := range validRecordValuesV1() { + add(value) + } + for _, value := range extra { + add(value) + } + return records +} + +func composeTestContractV1() *ReleaseContractV1 { + return validRecordValuesV1()[2].(*ReleaseContractV1) +} + +func composeTestTargetV1() *TargetRecordV1 { + return validRecordValuesV1()[3].(*TargetRecordV1) +} + +// A binding advertises a set of interpreters. Checking each artifact alone only +// proves that artifact serves some advertised interpreter, so a contract can +// advertise interpreters that no selected wheel can install. +func TestBindingInterpreterCoverageRequiresEveryAdvertisedVersionV1(t *testing.T) { + contract := &BindingContractV1{Name: "python", SupportedPython: []string{"3.11", "3.12"}} + cp311 := &BindingArtifactRecordV1{ID: "artifact-cp311", RequiresPython: ">=3.11,<3.12"} + cp312 := &BindingArtifactRecordV1{ID: "artifact-cp312", RequiresPython: ">=3.12,<3.13"} + universal := &BindingArtifactRecordV1{ID: "artifact-py3", RequiresPython: ">=3.11"} + + if err := validateBindingInterpreterCoverageV1(contract, []*BindingArtifactRecordV1{cp311, cp312}); err != nil { + t.Errorf("both interpreters covered by two wheels: %v", err) + } + if err := validateBindingInterpreterCoverageV1(contract, []*BindingArtifactRecordV1{universal}); err != nil { + t.Errorf("both interpreters covered by one universal wheel: %v", err) + } + // The carried finding from retired PR 83: advertising 3.11 and 3.12 while + // shipping only a cp311 wheel must fail, even though that wheel satisfies + // the per-artifact contract check. + err := validateBindingInterpreterCoverageV1(contract, []*BindingArtifactRecordV1{cp311}) + if err == nil || !strings.Contains(err.Error(), "3.12") { + t.Errorf("cp311-only artifact set error = %v, want an uncovered 3.12 rejection", err) + } + if err := validateBindingInterpreterCoverageV1(contract, nil); err == nil { + t.Error("empty artifact set covered every interpreter") + } +} + +func TestBindingInterpreterCoverageRejectsMalformedVersionsV1(t *testing.T) { + if err := validateBindingInterpreterCoverageV1( + &BindingContractV1{Name: "python", SupportedPython: []string{"banana"}}, + []*BindingArtifactRecordV1{{ID: "a", RequiresPython: ">=3.11"}}); err == nil { + t.Error("malformed contract interpreter accepted") + } + if err := validateBindingInterpreterCoverageV1( + &BindingContractV1{Name: "python", SupportedPython: []string{"3.11"}}, + []*BindingArtifactRecordV1{{ID: "a", RequiresPython: "not-a-specifier"}}); err == nil { + t.Error("malformed requires_python accepted") + } +} + +// Payloads reachable in one tuple install together, so they may not claim the +// same logical artifact or own overlapping directory trees. +func TestTuplePayloadsRejectCollisionsV1(t *testing.T) { + payload := func(id string, logical string, install string) *PayloadRecordV1 { + return &PayloadRecordV1{Schema: PayloadRecordSchemaV1, ID: id, + Platform: "linux/amd64", LogicalPath: logical, InstallDirectory: install} + } + build := func(payloads ...*PayloadRecordV1) (map[string]loadedRecordV1, []selectedPayloadReferenceV1) { + records := make(map[string]loadedRecordV1) + references := make([]selectedPayloadReferenceV1, 0, len(payloads)) + for _, value := range payloads { + records[value.ID] = loadedRecordV1{ID: value.ID, Schema: value.Schema, Digest: recordTestDigest, Value: value} + references = append(references, selectedPayloadReferenceV1{ + Reference: recordTestReference(value.ID), Selection: value.Selection}) + } + return records, references + } + + records, references := build( + payload("chromium", "tools/demo/chromium.zip", "chromium"), + payload("headless", "tools/demo/headless.zip", "headless-shell"), + payload("ffmpeg", "tools/demo/ffmpeg.zip", "ffmpeg")) + if err := validateTuplePayloadsV1(records, references, "linux/amd64"); err != nil { + t.Errorf("distinct coupled payloads rejected: %v", err) + } + + // The carried finding from retired PR 83, first half: a shared logical path. + records, references = build( + payload("chromium", "tools/demo/browser.zip", "chromium"), + payload("headless", "tools/demo/browser.zip", "headless-shell")) + err := validateTuplePayloadsV1(records, references, "linux/amd64") + if err == nil || !strings.Contains(err.Error(), "share logical path") { + t.Errorf("shared logical path error = %v", err) + } + + // Second half: overlapping install destinations, package requirements agreeing. + for _, testCase := range []struct{ name, left, right string }{ + {name: "identical", left: "chromium", right: "chromium"}, + {name: "nested", left: "chromium", right: "chromium/headless"}, + {name: "reverse nested", left: "chromium/headless", right: "chromium"}, + } { + t.Run(testCase.name, func(t *testing.T) { + records, references := build( + payload("left", "tools/demo/left.zip", testCase.left), + payload("right", "tools/demo/right.zip", testCase.right)) + err := validateTuplePayloadsV1(records, references, "linux/amd64") + if err == nil || !strings.Contains(err.Error(), "overlap install destinations") { + t.Errorf("error = %v, want an overlap rejection", err) + } + }) + } + + // A shared unowned parent is allowed: neither owns the other's tree. + records, references = build( + payload("left", "tools/demo/left.zip", "browsers/chromium"), + payload("right", "tools/demo/right.zip", "browsers/firefox")) + if err := validateTuplePayloadsV1(records, references, "linux/amd64"); err != nil { + t.Errorf("siblings under an unowned parent rejected: %v", err) + } + + // A prefix that is not a segment boundary is not containment. + records, references = build( + payload("left", "tools/demo/left.zip", "chromium"), + payload("right", "tools/demo/right.zip", "chromium-headless")) + if err := validateTuplePayloadsV1(records, references, "linux/amd64"); err != nil { + t.Errorf("non-boundary prefix treated as overlap: %v", err) + } +} + +func TestTuplePayloadsRejectUnresolvableAndMistypedReferencesV1(t *testing.T) { + records := composeTestRecordsV1() + absent := []selectedPayloadReferenceV1{{Reference: recordTestReference("tool:demo/releases/1.2.3/payloads/absent")}} + if err := validateTuplePayloadsV1(records, absent, "linux/amd64"); err == nil { + t.Error("unresolvable payload reference accepted") + } + contract := composeTestContractV1() + mistyped := []selectedPayloadReferenceV1{{Reference: recordTestReference(contract.ID)}} + if err := validateTuplePayloadsV1(records, mistyped, "linux/amd64"); err == nil { + t.Error("non-payload record accepted as a payload") + } +} + +func TestRecordPathOverlapsV1(t *testing.T) { + for _, testCase := range []struct { + left, right string + want bool + }{ + {left: "a", right: "a", want: true}, + {left: "a", right: "a/b", want: true}, + {left: "a/b", right: "a", want: true}, + {left: "a", right: "ab", want: false}, + {left: "a/b", right: "a/c", want: false}, + {left: "", right: "a", want: false}, + } { + if got := recordPathOverlapsV1(testCase.left, testCase.right); got != testCase.want { + t.Errorf("recordPathOverlapsV1(%q, %q) = %v, want %v", testCase.left, testCase.right, got, testCase.want) + } + } +} + +// Probe identity is a semantic key: identical probes deduplicate, but the same +// executable invoked differently is a conflict rather than two probes. +func TestTupleContributionsRejectConflictingProbesV1(t *testing.T) { + contract := composeTestContractV1() + target := composeTestTargetV1() + target.Probes = []RecordProbeV1{ + {Path: "/opt/demo/bin/demo", Args: []string{"--version"}, Network: "none"}, + } + records := composeTestRecordsV1() + tuple := supportTupleV1{Context: "build", Selections: []string{}, Parameters: []ParameterValueV1{}} + + if err := validateTupleContributionsV1(records, contract, target, tuple); err != nil { + t.Fatalf("single probe rejected: %v", err) + } + target.Probes = append(target.Probes, RecordProbeV1{Path: "/opt/demo/bin/demo", Args: []string{"--version"}, Network: "none"}) + if err := validateTupleContributionsV1(records, contract, target, tuple); err != nil { + t.Errorf("identical duplicate probe rejected instead of deduplicated: %v", err) + } + // Probe identity is the complete canonical value, so the same executable + // invoked with different arguments is two probes rather than a conflict. + target.Probes[1].Args = []string{"--help"} + if err := validateTupleContributionsV1(records, contract, target, tuple); err != nil { + t.Errorf("distinct probes on one executable rejected as a conflict: %v", err) + } +} + +// A target may advertise a subset of the contract's symbols, and the tuples it +// advertises must follow that subset rather than the full contract list. +func TestSupportTuplesFollowTargetAdvertisedSubsetV1(t *testing.T) { + contract := composeRichContractV1() + contract.Binding.Options = []string{"node", "python"} + target := composeRichTargetV1() + + full, err := targetSupportTuplesV1(contract, target) + if err != nil { + t.Fatal(err) + } + for _, tuple := range full { + if tuple.Binding == "node" { + t.Fatalf("enumerated a binding the target does not advertise: %+v", tuple) + } + } + if len(full) != 2 { + t.Fatalf("python with two selections should advertise two tuples, got %d", len(full)) + } + + // Dropping a selection from the target drops its tuples, and the contract + // still validates because advertising a subset is legal. + target.Selections = target.Selections[:1] + if err := validateTargetAgainstContractV1(contract, target); err != nil { + t.Fatalf("advertising a subset of selections was rejected: %v", err) + } + narrowed, err := targetSupportTuplesV1(contract, target) + if err != nil { + t.Fatal(err) + } + if len(narrowed) != 1 { + t.Fatalf("one advertised selection should advertise one tuple, got %d", len(narrowed)) + } + if len(narrowed[0].Selections) != 1 || narrowed[0].Selections[0] != "chromium" { + t.Errorf("narrowed tuple = %+v", narrowed[0]) + } + + // Fixture coverage follows the narrowed set: one fixture now suffices. + records := composeTestRecordsV1() + chromium := composeFixtureV1("debian-12-amd64-chromium", "python", "chromium") + if err := validateTargetFixtureCoverageV1(records, contract, target, + []*IntegrationFixtureRecordV1{chromium}); err != nil { + t.Errorf("coverage of the narrowed tuple set rejected: %v", err) + } + firefox := composeFixtureV1("debian-12-amd64-firefox", "python", "firefox") + if err := validateTargetFixtureCoverageV1(records, contract, target, + []*IntegrationFixtureRecordV1{chromium, firefox}); err == nil { + t.Error("a fixture for an unadvertised selection was accepted") + } +} + +// Unselected contributions never enter a tuple, so a collision that only exists +// between two different selections is not a collision in either tuple. +func TestUnselectedContributionsNeverLeakIntoATupleV1(t *testing.T) { + const release = "tool:demo/releases/1.2.3" + colliding := func(id string, selection string) *PayloadRecordV1 { + return &PayloadRecordV1{Schema: PayloadRecordSchemaV1, ID: id, Selection: selection, + Platform: "linux/amd64", LogicalPath: "tools/demo/browser.zip", InstallDirectory: "browser"} + } + chromium := colliding(release+"/payloads/chromium/browser-linux-amd64", "chromium") + firefox := colliding(release+"/payloads/firefox/browser-linux-amd64", "firefox") + records := composeTestRecordsV1(chromium, firefox) + + contract := composeTestContractV1() + target := composeTestTargetV1() + target.Payloads = []RecordReferenceV1{} + target.Selections = []TargetSelectionV1{ + {Name: "chromium", Payloads: []RecordReferenceV1{recordTestReference(chromium.ID)}}, + {Name: "firefox", Payloads: []RecordReferenceV1{recordTestReference(firefox.ID)}}, + } + + for _, selected := range []string{"chromium", "firefox"} { + tuple := supportTupleV1{Context: "build", Selections: []string{selected}, Parameters: []ParameterValueV1{}} + if err := validateTupleContributionsV1(records, contract, target, tuple); err != nil { + t.Errorf("selecting only %q surfaced a collision with the unselected payload: %v", selected, err) + } + } + // Selecting both together is the tuple where the collision is real. + both := supportTupleV1{Context: "build", Selections: []string{"chromium", "firefox"}, Parameters: []ParameterValueV1{}} + if err := validateTupleContributionsV1(records, contract, target, both); err == nil { + t.Error("selecting both colliding payloads together was accepted") + } +} + +func TestSupportTupleKeyIsOrderIndependentIdentityV1(t *testing.T) { + left := supportTupleV1{Context: "build", Binding: "python", Selections: []string{"chromium"}, + Parameters: []ParameterValueV1{{Name: "channel", Value: "stable"}}} + right := supportTupleV1{Context: "build", Binding: "python", Selections: []string{"chromium"}, + Parameters: []ParameterValueV1{{Name: "channel", Value: "stable"}}} + leftKey, err := supportTupleKeyV1(left) + if err != nil { + t.Fatal(err) + } + rightKey, err := supportTupleKeyV1(right) + if err != nil { + t.Fatal(err) + } + if leftKey != rightKey { + t.Errorf("equal tuples produced different keys:\n%s\n%s", leftKey, rightKey) + } + right.Selections = []string{"firefox"} + otherKey, err := supportTupleKeyV1(right) + if err != nil { + t.Fatal(err) + } + if otherKey == leftKey { + t.Error("different selections produced the same key") + } +} + +func TestTargetSupportTuplesEnumerateAndStayBoundedV1(t *testing.T) { + contract := composeTestContractV1() + target := composeTestTargetV1() + tuples, err := targetSupportTuplesV1(contract, target) + if err != nil { + t.Fatalf("enumerating the sample target: %v", err) + } + if len(tuples) == 0 { + t.Fatal("sample target advertises no support tuple") + } + keys := make(map[string]struct{}, len(tuples)) + for _, tuple := range tuples { + key, err := supportTupleKeyV1(tuple) + if err != nil { + t.Fatal(err) + } + if _, exists := keys[key]; exists { + t.Errorf("duplicate tuple enumerated: %s", key) + } + keys[key] = struct{}{} + } + + // An integer parameter whose domain exceeds the validation-case limit must + // fail closed rather than enumerate. + wide := *contract + wide.Parameters = []ParameterSchemaV1{{Name: "port", Type: "integer", + Minimum: "0", Maximum: fmt.Sprint(maxDefinitionValidationCases + 1), Values: []string{}}} + if _, err := targetSupportTuplesV1(&wide, target); err == nil { + t.Error("an unbounded parameter domain enumerated instead of failing closed") + } +} + +func TestTargetAgainstContractValidatesAdvertisedSubsetV1(t *testing.T) { + for _, testCase := range []struct { + name string + mutate func(*ReleaseContractV1, *TargetRecordV1) + wantSub string + }{ + {name: "binding mapping not declared by the contract", wantSub: "not declared by the release contract", + mutate: func(c *ReleaseContractV1, target *TargetRecordV1) { + c.Binding.Options = []string{"python"} + target.Bindings = []TargetBindingV1{{Name: "node"}} + }}, + {name: "required binding advertised by no mapping", wantSub: "advertises none", + mutate: func(c *ReleaseContractV1, target *TargetRecordV1) { + c.Binding = BindingRequestV1{Options: []string{"python"}, Required: true, Default: "python"} + target.Bindings = []TargetBindingV1{} + }}, + {name: "contract default binding not advertised", wantSub: "default binding", + mutate: func(c *ReleaseContractV1, target *TargetRecordV1) { + c.Binding = BindingRequestV1{Options: []string{"node", "python"}, Required: true, Default: "python"} + target.Bindings = []TargetBindingV1{{Name: "node"}} + }}, + {name: "selection mapping not declared by the contract", wantSub: "not declared by the release contract", + mutate: func(c *ReleaseContractV1, target *TargetRecordV1) { + c.Selections.Options = []string{"chromium"} + target.Selections = []TargetSelectionV1{{Name: "webkit"}} + }}, + {name: "fewer selections advertised than required", wantSub: "requires 1 selections", + mutate: func(c *ReleaseContractV1, target *TargetRecordV1) { + c.Selections = SelectionRequestV1{Options: []string{"chromium"}, Minimum: "1", Maximum: "1", + Defaults: []string{}, CompatibilityGroups: [][]string{{"chromium"}}} + target.Selections = []TargetSelectionV1{} + }}, + {name: "undeclared parameter constraint", wantSub: "not declared by the release contract", + mutate: func(c *ReleaseContractV1, target *TargetRecordV1) { + target.Parameters = []TargetParameterConstraintV1{{Name: "absent", Values: []string{"x"}}} + }}, + } { + t.Run(testCase.name, func(t *testing.T) { + contract := composeTestContractV1() + target := composeTestTargetV1() + testCase.mutate(contract, target) + err := validateTargetAgainstContractV1(contract, target) + if err == nil || !strings.Contains(err.Error(), testCase.wantSub) { + t.Errorf("error = %v, want substring %q", err, testCase.wantSub) + } + }) + } +} + +func TestTargetAgainstContractAcceptsTheSampleTargetV1(t *testing.T) { + if err := validateTargetAgainstContractV1(composeTestContractV1(), composeTestTargetV1()); err != nil { + t.Errorf("the shared valid target and contract disagree: %v", err) + } +} + +// composeRichContractV1 advertises a binding, two mutually exclusive +// selections, and one enum parameter, so a target built from it advertises +// more than one support tuple and fixture coverage becomes meaningful. +func composeRichContractV1() *ReleaseContractV1 { + contract := composeTestContractV1() + contract.Binding = BindingRequestV1{Options: []string{"python"}, Required: true, Default: "python"} + contract.Selections = SelectionRequestV1{ + Options: []string{"chromium", "firefox"}, Minimum: "1", Maximum: "1", + Defaults: []string{}, CompatibilityGroups: [][]string{{"chromium", "firefox"}}, + } + contract.Parameters = []ParameterSchemaV1{} + return contract +} + +func composeRichTargetV1() *TargetRecordV1 { + target := composeTestTargetV1() + target.Payloads = []RecordReferenceV1{} + target.Bindings = targetBindingWithArtifactV1("tool:demo/releases/1.2.3/bindings/python/artifacts/linux-amd64") + target.Selections = []TargetSelectionV1{ + {Name: "chromium", Payloads: []RecordReferenceV1{}, PackageSets: []RecordReferenceV1{}, Exports: []ToolExportV1{}, Probes: []RecordProbeV1{}}, + {Name: "firefox", Payloads: []RecordReferenceV1{}, PackageSets: []RecordReferenceV1{}, Exports: []ToolExportV1{}, Probes: []RecordProbeV1{}}, + } + return target +} + +func composeFixtureV1(name string, binding string, selections ...string) *IntegrationFixtureRecordV1 { + return &IntegrationFixtureRecordV1{ + Schema: IntegrationFixtureSchemaV1, ID: "tool:demo/releases/1.2.3/validation/fixtures/" + name, + Name: name, Context: "build", Binding: binding, + Selections: append([]string{}, selections...), Parameters: []ParameterValueV1{}, + } +} + +// The slice's headline acceptance criterion: every tuple a target advertises +// must have a fixture, and no fixture may cover a tuple the target does not +// advertise or duplicate one another fixture already covers. +func TestTargetFixtureCoverageMatchesAdvertisedTuplesV1(t *testing.T) { + contract := composeRichContractV1() + target := composeRichTargetV1() + records := composeTestRecordsV1() + + tuples, err := targetSupportTuplesV1(contract, target) + if err != nil { + t.Fatal(err) + } + if len(tuples) != 2 { + t.Fatalf("expected two advertised tuples, got %d", len(tuples)) + } + + chromium := composeFixtureV1("debian-12-amd64-chromium", "python", "chromium") + firefox := composeFixtureV1("debian-12-amd64-firefox", "python", "firefox") + + if err := validateTargetFixtureCoverageV1(records, contract, target, + []*IntegrationFixtureRecordV1{chromium, firefox}); err != nil { + t.Errorf("complete coverage rejected: %v", err) + } + + err = validateTargetFixtureCoverageV1(records, contract, target, []*IntegrationFixtureRecordV1{chromium}) + if err == nil || !strings.Contains(err.Error(), "do not cover support tuple") { + t.Errorf("missing firefox fixture error = %v", err) + } + + err = validateTargetFixtureCoverageV1(records, contract, target, nil) + if err == nil || !strings.Contains(err.Error(), "do not cover support tuple") { + t.Errorf("no fixtures at all error = %v", err) + } + + duplicate := composeFixtureV1("debian-12-amd64-chromium-again", "python", "chromium") + err = validateTargetFixtureCoverageV1(records, contract, target, + []*IntegrationFixtureRecordV1{chromium, firefox, duplicate}) + if err == nil || !strings.Contains(err.Error(), "cover the same support tuple") { + t.Errorf("duplicate fixture error = %v", err) + } + + unsupported := composeFixtureV1("debian-12-amd64-webkit", "python", "webkit") + err = validateTargetFixtureCoverageV1(records, contract, target, + []*IntegrationFixtureRecordV1{chromium, firefox, unsupported}) + if err == nil || !strings.Contains(err.Error(), "unsupported tuple") { + t.Errorf("fixture for an unadvertised tuple error = %v", err) + } +} + +// A fixture that omits its binding or selections inherits the contract +// defaults, so it must normalize to the same tuple an explicit fixture does. +func TestNormalizedFixtureTupleAppliesContractDefaultsV1(t *testing.T) { + contract := composeRichContractV1() + contract.Selections.Minimum = "0" + contract.Selections.Defaults = []string{"chromium"} + + explicit := normalizedFixtureTupleV1(contract, composeFixtureV1("explicit", "python", "chromium")) + implicit := normalizedFixtureTupleV1(contract, composeFixtureV1("implicit", "")) + explicitKey, err := supportTupleKeyV1(explicit) + if err != nil { + t.Fatal(err) + } + implicitKey, err := supportTupleKeyV1(implicit) + if err != nil { + t.Fatal(err) + } + if explicitKey != implicitKey { + t.Errorf("defaults did not normalize:\n explicit %s\n implicit %s", explicitKey, implicitKey) + } + + // A declared parameter default enters the tuple even when the fixture omits it. + defaultValue := "stable" + contract.Parameters = []ParameterSchemaV1{{Name: "channel", Type: "enum", + Values: []string{"beta", "stable"}, Default: &defaultValue}} + tuple := normalizedFixtureTupleV1(contract, composeFixtureV1("defaulted", "python", "chromium")) + if len(tuple.Parameters) != 1 || tuple.Parameters[0].Name != "channel" || tuple.Parameters[0].Value != "stable" { + t.Errorf("parameter default missing from normalized tuple: %+v", tuple.Parameters) + } +} + +func TestFixtureAgainstTargetRejectsUndeclaredAndUnavailableV1(t *testing.T) { + for _, testCase := range []struct { + name string + mutate func(*ReleaseContractV1, *TargetRecordV1, *IntegrationFixtureRecordV1) + wantSub string + }{ + {name: "context not declared", wantSub: "context", + mutate: func(c *ReleaseContractV1, target *TargetRecordV1, f *IntegrationFixtureRecordV1) { + f.Context = "runtime" + }}, + {name: "binding not declared", wantSub: "binding", + mutate: func(c *ReleaseContractV1, target *TargetRecordV1, f *IntegrationFixtureRecordV1) { + f.Binding = "node" + }}, + {name: "binding unavailable on target", wantSub: "unavailable on the target", + mutate: func(c *ReleaseContractV1, target *TargetRecordV1, f *IntegrationFixtureRecordV1) { + target.Bindings = []TargetBindingV1{} + }}, + {name: "selection not declared", wantSub: "selection", + mutate: func(c *ReleaseContractV1, target *TargetRecordV1, f *IntegrationFixtureRecordV1) { + f.Selections = []string{"webkit"} + }}, + {name: "selection unavailable on target", wantSub: "unavailable on the target", + mutate: func(c *ReleaseContractV1, target *TargetRecordV1, f *IntegrationFixtureRecordV1) { + target.Selections = []TargetSelectionV1{} + }}, + {name: "too many selections", wantSub: "do not satisfy", + mutate: func(c *ReleaseContractV1, target *TargetRecordV1, f *IntegrationFixtureRecordV1) { + f.Selections = []string{"chromium", "firefox"} + }}, + {name: "parameter outside the contract domain", wantSub: "outside the contract or target domain", + mutate: func(c *ReleaseContractV1, target *TargetRecordV1, f *IntegrationFixtureRecordV1) { + c.Parameters = []ParameterSchemaV1{{Name: "channel", Type: "enum", Values: []string{"stable"}}} + f.Parameters = []ParameterValueV1{{Name: "channel", Value: "nightly"}} + }}, + {name: "parameter outside the target narrowing", wantSub: "outside the contract or target domain", + mutate: func(c *ReleaseContractV1, target *TargetRecordV1, f *IntegrationFixtureRecordV1) { + c.Parameters = []ParameterSchemaV1{{Name: "channel", Type: "enum", Values: []string{"beta", "stable"}}} + target.Parameters = []TargetParameterConstraintV1{{Name: "channel", Values: []string{"stable"}}} + f.Parameters = []ParameterValueV1{{Name: "channel", Value: "beta"}} + }}, + {name: "required parameter missing", wantSub: "required parameter", + mutate: func(c *ReleaseContractV1, target *TargetRecordV1, f *IntegrationFixtureRecordV1) { + c.Parameters = []ParameterSchemaV1{{Name: "channel", Type: "enum", + Values: []string{"stable"}, Required: true}} + }}, + } { + t.Run(testCase.name, func(t *testing.T) { + contract := composeRichContractV1() + target := composeRichTargetV1() + fixture := composeFixtureV1("debian-12-amd64-chromium", "python", "chromium") + testCase.mutate(contract, target, fixture) + err := validateFixtureAgainstTargetV1(contract, target, fixture) + if err == nil || !strings.Contains(err.Error(), testCase.wantSub) { + t.Errorf("error = %v, want substring %q", err, testCase.wantSub) + } + }) + } +} + +func TestFixtureAgainstTargetAcceptsAnAdvertisedFixtureV1(t *testing.T) { + contract := composeRichContractV1() + target := composeRichTargetV1() + fixture := composeFixtureV1("debian-12-amd64-chromium", "python", "chromium") + if err := validateFixtureAgainstTargetV1(contract, target, fixture); err != nil { + t.Errorf("an advertised fixture was rejected: %v", err) + } + // Omitting the binding falls back to the contract default rather than failing. + fixture.Binding = "" + if err := validateFixtureAgainstTargetV1(contract, target, fixture); err != nil { + t.Errorf("fixture relying on the default binding rejected: %v", err) + } + // A required binding with no default and no fixture value must fail. + contract.Binding.Default = "" + if err := validateFixtureAgainstTargetV1(contract, target, fixture); err == nil { + t.Error("missing required binding accepted") + } +} + +func TestTargetBindingsAgainstContractsResolveAndCoverV1(t *testing.T) { + target := composeRichTargetV1() + records := composeTestRecordsV1() + if err := validateTargetBindingsAgainstContractsV1(records, target); err != nil { + t.Errorf("the sample binding and its artifact disagree: %v", err) + } + + // The contract advertises 3.10 while the only wheel requires >=3.11, so an + // advertised interpreter has nothing to install. + contract := *(validRecordValuesV1()[4].(*BindingContractV1)) + contract.SupportedPython = append([]string{"3.10"}, contract.SupportedPython...) + narrowed := composeTestRecordsV1() + narrowed[contract.ID] = loadedRecordV1{ID: contract.ID, Schema: contract.Schema, Digest: recordTestDigest, Value: &contract} + err := validateTargetBindingsAgainstContractsV1(narrowed, target) + if err == nil || !strings.Contains(err.Error(), "no selected artifact supports it") { + t.Errorf("uncovered interpreter error = %v", err) + } + + // An unresolvable artifact reference fails rather than being skipped. + missing := composeRichTargetV1() + missing.Bindings = targetBindingWithArtifactV1("tool:demo/releases/1.2.3/bindings/python/artifacts/linux-arm64") + if err := validateTargetBindingsAgainstContractsV1(records, missing); err == nil { + t.Error("unresolvable binding artifact accepted") + } + + // A binding contract reference that resolves to another record type fails. + mistyped := composeRichTargetV1() + mistyped.Bindings[0].Contract = recordTestReference("tool:demo/releases/1.2.3/contract") + if err := validateTargetBindingsAgainstContractsV1(records, mistyped); err == nil { + t.Error("release contract accepted as a binding contract") + } +} + +func TestPackageSetReferencesRequireAMatchingManagerV1(t *testing.T) { + records := composeTestRecordsV1() + target := composeTestTargetV1() + packageSet := validRecordValuesV1()[8].(*NativePackageSetV1) + + if err := validatePackageSetReferencesV1(records, []RecordReferenceV1{recordTestReference(packageSet.ID)}, target); err != nil { + t.Errorf("matching apt package set rejected: %v", err) + } + if err := validatePackageSetReferencesV1(records, []RecordReferenceV1{recordTestReference("tool:demo/releases/1.2.3/package-sets/absent")}, target); err == nil { + t.Error("unresolvable package set accepted") + } + other := *packageSet + other.Manager = "apk" + mismatched := composeTestRecordsV1() + mismatched[other.ID] = loadedRecordV1{ID: other.ID, Schema: other.Schema, Digest: recordTestDigest, Value: &other} + err := validatePackageSetReferencesV1(mismatched, []RecordReferenceV1{recordTestReference(other.ID)}, target) + if err == nil || !strings.Contains(err.Error(), "incompatible package manager") { + t.Errorf("mismatched package manager error = %v", err) + } +} + +func TestTargetParameterAllowsRespectsNarrowingV1(t *testing.T) { + enum := []TargetParameterConstraintV1{{Name: "channel", Values: []string{"stable"}}} + if !targetParameterAllowsV1(enum, "channel", "stable") { + t.Error("narrowed enum rejected its own value") + } + if targetParameterAllowsV1(enum, "channel", "beta") { + t.Error("narrowed enum accepted an excluded value") + } + if !targetParameterAllowsV1(enum, "absent", "anything") { + t.Error("an unconstrained parameter was narrowed") + } + ranged := []TargetParameterConstraintV1{{Name: "port", Values: []string{}, Minimum: "10", Maximum: "20"}} + if !targetParameterAllowsV1(ranged, "port", "15") { + t.Error("in-range value rejected") + } + if targetParameterAllowsV1(ranged, "port", "21") { + t.Error("out-of-range value accepted") + } +} + +// A target may narrow a contract parameter but never widen it, change its type, +// or exclude the contract default. +func TestTargetIntegerNarrowingV1(t *testing.T) { + base := func() (*ReleaseContractV1, *TargetRecordV1) { + contract := composeTestContractV1() + contract.Parameters = []ParameterSchemaV1{{Name: "workers", Type: "integer", + Minimum: "1", Maximum: "8", Values: []string{}}} + target := composeTestTargetV1() + target.Parameters = []TargetParameterConstraintV1{{Name: "workers", Values: []string{}, Minimum: "2", Maximum: "4"}} + return contract, target + } + + contract, target := base() + if err := validateTargetAgainstContractV1(contract, target); err != nil { + t.Errorf("a strictly narrower range was rejected: %v", err) + } + + contract, target = base() + target.Parameters[0].Maximum = "9" + err := validateTargetAgainstContractV1(contract, target) + if err == nil || !strings.Contains(err.Error(), "widens the contract domain") { + t.Errorf("widening the maximum error = %v", err) + } + + contract, target = base() + target.Parameters[0].Minimum = "0" + err = validateTargetAgainstContractV1(contract, target) + if err == nil || !strings.Contains(err.Error(), "widens the contract domain") { + t.Errorf("widening the minimum error = %v", err) + } + + // Narrowing must not exclude the contract default. + contract, target = base() + defaultValue := "8" + contract.Parameters[0].Default = &defaultValue + err = validateTargetAgainstContractV1(contract, target) + if err == nil || !strings.Contains(err.Error(), "excludes the contract default") { + t.Errorf("range excluding the default error = %v", err) + } + inRange := "3" + contract.Parameters[0].Default = &inRange + if err := validateTargetAgainstContractV1(contract, target); err != nil { + t.Errorf("range containing the default rejected: %v", err) + } + + // A range constraint cannot be applied to a non-integer parameter. + contract, target = base() + contract.Parameters[0] = ParameterSchemaV1{Name: "workers", Type: "enum", Values: []string{"few", "many"}} + err = validateTargetAgainstContractV1(contract, target) + if err == nil || !strings.Contains(err.Error(), "incompatible with contract type") { + t.Errorf("range on an enum parameter error = %v", err) + } + + // An enum narrowing that drops the contract default is rejected too. + contract, target = base() + contract.Parameters[0] = ParameterSchemaV1{Name: "workers", Type: "enum", Values: []string{"few", "many"}, Default: &[]string{"many"}[0]} + target.Parameters[0] = TargetParameterConstraintV1{Name: "workers", Values: []string{"few"}} + err = validateTargetAgainstContractV1(contract, target) + if err == nil || !strings.Contains(err.Error(), "excludes the contract default") { + t.Errorf("enum narrowing excluding the default error = %v", err) + } +} + +// Domains drive tuple enumeration, so an unconstrained parameter enumerates its +// whole contract domain and a narrowed one enumerates only what survives. +func TestTargetParameterDomainV1(t *testing.T) { + values := func(domain []*string) []string { + out := make([]string, 0, len(domain)) + for _, value := range domain { + if value == nil { + out = append(out, "") + continue + } + out = append(out, *value) + } + return out + } + + boolean := ParameterSchemaV1{Name: "headless", Type: "boolean", Required: true, Values: []string{}} + domain, err := targetParameterDomainV1(boolean, nil) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(values(domain), ","); got != "false,true" { + t.Errorf("boolean domain = %q", got) + } + + integer := ParameterSchemaV1{Name: "workers", Type: "integer", Required: true, Minimum: "1", Maximum: "3", Values: []string{}} + domain, err = targetParameterDomainV1(integer, nil) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(values(domain), ","); got != "1,2,3" { + t.Errorf("integer domain = %q", got) + } + domain, err = targetParameterDomainV1(integer, + []TargetParameterConstraintV1{{Name: "workers", Values: []string{}, Minimum: "2", Maximum: "3"}}) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(values(domain), ","); got != "2,3" { + t.Errorf("narrowed integer domain = %q", got) + } + + // An optional parameter with no default also enumerates its absence. + optional := ParameterSchemaV1{Name: "channel", Type: "enum", Values: []string{"beta", "stable"}} + domain, err = targetParameterDomainV1(optional, nil) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(values(domain), ","); got != ",beta,stable" { + t.Errorf("optional enum domain = %q", got) + } + + if _, err := targetParameterDomainV1(ParameterSchemaV1{Name: "x", Type: "duration", Values: []string{}}, nil); err == nil { + t.Error("unsupported parameter type produced a domain") + } +} + +// A target leaf owns data specific to one architecture, so a payload it +// installs must be built for that architecture. Record-local validation only +// proves the payload agrees with its own ID. +func TestTuplePayloadsRequireTheTargetPlatformV1(t *testing.T) { + arm := &PayloadRecordV1{Schema: PayloadRecordSchemaV1, ID: "tool:demo/releases/1.2.3/payloads/demo-linux-arm64", + Platform: "linux/arm64", LogicalPath: "tools/demo/demo.tar.gz", InstallDirectory: "demo"} + records := map[string]loadedRecordV1{arm.ID: {ID: arm.ID, Schema: arm.Schema, Digest: recordTestDigest, Value: arm}} + references := []selectedPayloadReferenceV1{{Reference: recordTestReference(arm.ID)}} + + if err := validateTuplePayloadsV1(records, references, "linux/arm64"); err != nil { + t.Errorf("matching platform rejected: %v", err) + } + err := validateTuplePayloadsV1(records, references, "linux/amd64") + if err == nil || !strings.Contains(err.Error(), "built for platform") { + t.Errorf("arm64 payload on an amd64 target error = %v", err) + } +} + +// Selection ownership is declared on the payload, not inferred from the mapping +// that references it, so a chromium entry may not install a firefox payload. +func TestTuplePayloadsRequireDeclaredSelectionOwnershipV1(t *testing.T) { + const release = "tool:demo/releases/1.2.3" + firefox := &PayloadRecordV1{Schema: PayloadRecordSchemaV1, ID: release + "/payloads/firefox/browser-linux-amd64", + Selection: "firefox", Platform: "linux/amd64", LogicalPath: "tools/demo/firefox.zip", InstallDirectory: "firefox"} + unconditional := &PayloadRecordV1{Schema: PayloadRecordSchemaV1, ID: release + "/payloads/demo-linux-amd64", + Platform: "linux/amd64", LogicalPath: "tools/demo/demo.tar.gz", InstallDirectory: "demo"} + records := map[string]loadedRecordV1{ + firefox.ID: {ID: firefox.ID, Schema: firefox.Schema, Digest: recordTestDigest, Value: firefox}, + unconditional.ID: {ID: unconditional.ID, Schema: unconditional.Schema, Digest: recordTestDigest, Value: unconditional}, + } + + owned := []selectedPayloadReferenceV1{{Reference: recordTestReference(firefox.ID), Selection: "firefox"}} + if err := validateTuplePayloadsV1(records, owned, "linux/amd64"); err != nil { + t.Errorf("a payload referenced by its own selection was rejected: %v", err) + } + + stolen := []selectedPayloadReferenceV1{{Reference: recordTestReference(firefox.ID), Selection: "chromium"}} + err := validateTuplePayloadsV1(records, stolen, "linux/amd64") + if err == nil || !strings.Contains(err.Error(), "which belongs to selection") { + t.Errorf("chromium installing a firefox payload error = %v", err) + } + + // An unconditional reference must name a payload that belongs to no selection. + leaked := []selectedPayloadReferenceV1{{Reference: recordTestReference(firefox.ID)}} + err = validateTuplePayloadsV1(records, leaked, "linux/amd64") + if err == nil || !strings.Contains(err.Error(), "unconditional target payload") { + t.Errorf("selection payload referenced unconditionally error = %v", err) + } + + unconditionalRef := []selectedPayloadReferenceV1{{Reference: recordTestReference(unconditional.ID)}} + if err := validateTuplePayloadsV1(records, unconditionalRef, "linux/amd64"); err != nil { + t.Errorf("an unconditional payload was rejected: %v", err) + } + claimed := []selectedPayloadReferenceV1{{Reference: recordTestReference(unconditional.ID), Selection: "chromium"}} + if err := validateTuplePayloadsV1(records, claimed, "linux/amd64"); err == nil { + t.Error("a selection claimed an unconditional payload") + } +} + +// The per-artifact contract check proves one wheel agrees with the binding +// contract that consumes it: same distribution, a version its requirement +// admits, and an interpreter the contract advertises. +func TestBindingArtifactAgainstContractV1(t *testing.T) { + contract := func() *BindingContractV1 { + return &BindingContractV1{Name: "python", Package: "demo", + Requirements: []string{"demo==1.2.3", "support>=1,<2"}, + SupportedPython: []string{"3.11", "3.12"}} + } + artifact := func() *BindingArtifactRecordV1 { + return &BindingArtifactRecordV1{ID: "artifact", + Filename: "demo-1.2.3-py3-none-manylinux1_x86_64.whl", RequiresPython: ">=3.11"} + } + + if err := validateBindingArtifactAgainstContractV1(contract(), artifact()); err != nil { + t.Errorf("an agreeing wheel was rejected: %v", err) + } + + for _, testCase := range []struct { + name string + mutate func(*BindingContractV1, *BindingArtifactRecordV1) + wantSub string + }{ + {name: "wheel identity has too few parts", wantSub: "wheel identity is invalid", + mutate: func(c *BindingContractV1, a *BindingArtifactRecordV1) { a.Filename = "demo-1.2.3.whl" }}, + {name: "distribution does not match the contract package", wantSub: "does not match contract package", + mutate: func(c *BindingContractV1, a *BindingArtifactRecordV1) { + a.Filename = "other-1.2.3-py3-none-manylinux1_x86_64.whl" + }}, + {name: "wheel version is not PEP 440", wantSub: "wheel version is invalid", + mutate: func(c *BindingContractV1, a *BindingArtifactRecordV1) { + a.Filename = "demo-banana-py3-none-manylinux1_x86_64.whl" + }}, + {name: "wheel version violates the contract requirement", wantSub: "does not satisfy contract requirement", + mutate: func(c *BindingContractV1, a *BindingArtifactRecordV1) { + a.Filename = "demo-2.0.0-py3-none-manylinux1_x86_64.whl" + }}, + {name: "contract package has no requirement", wantSub: "has no binding requirement", + mutate: func(c *BindingContractV1, a *BindingArtifactRecordV1) { + c.Requirements = []string{"support>=1,<2"} + }}, + {name: "requires_python is malformed", wantSub: "requires_python is invalid", + mutate: func(c *BindingContractV1, a *BindingArtifactRecordV1) { a.RequiresPython = "not-a-specifier" }}, + {name: "requires_python excludes every advertised interpreter", wantSub: "excludes every contract interpreter", + mutate: func(c *BindingContractV1, a *BindingArtifactRecordV1) { a.RequiresPython = ">=4.0" }}, + } { + t.Run(testCase.name, func(t *testing.T) { + c, a := contract(), artifact() + testCase.mutate(c, a) + err := validateBindingArtifactAgainstContractV1(c, a) + if err == nil || !strings.Contains(err.Error(), testCase.wantSub) { + t.Errorf("error = %v, want substring %q", err, testCase.wantSub) + } + }) + } + + // PTD-01 rejects extras in a package root requirement and record-local + // validation rejects a contract carrying one, so a requirement that fails to + // parse here is a defect and must surface rather than be skipped over. + c, a := contract(), artifact() + c.Requirements = []string{"demo[http]==1.2.3"} + err := validateBindingArtifactAgainstContractV1(c, a) + if err == nil || !strings.Contains(err.Error(), "must not request extras") { + t.Errorf("unparseable contract requirement error = %v, want it surfaced", err) + } +} + +// Contributions selected together must agree: two package sets cannot pin one +// package differently, and two exports cannot claim one name for two paths. +func TestTupleContributionsRejectPackageAndExportConflictsV1(t *testing.T) { + const release = "tool:demo/releases/1.2.3" + packageSet := func(id string, requirements ...string) *NativePackageSetV1 { + return &NativePackageSetV1{Schema: NativePackageSetSchemaV1, ID: id, Manager: "apt", + Requirements: append([]string{}, requirements...)} + } + base := packageSet(release+"/package-sets/base", "libdemo=1.0") + conflicting := packageSet(release+"/package-sets/conflicting", "libdemo=2.0") + agreeing := packageSet(release+"/package-sets/agreeing", "libdemo=1.0") + records := composeTestRecordsV1(base, conflicting, agreeing) + + contract := composeTestContractV1() + target := composeTestTargetV1() + target.Payloads = []RecordReferenceV1{} + target.PackageSets = []RecordReferenceV1{recordTestReference(base.ID)} + target.Selections = []TargetSelectionV1{ + {Name: "conflicting", PackageSets: []RecordReferenceV1{recordTestReference(conflicting.ID)}}, + {Name: "agreeing", PackageSets: []RecordReferenceV1{recordTestReference(agreeing.ID)}}, + } + tuple := func(selection string) supportTupleV1 { + return supportTupleV1{Context: "build", Selections: []string{selection}, Parameters: []ParameterValueV1{}} + } + + if err := validateTupleContributionsV1(records, contract, target, tuple("agreeing")); err != nil { + t.Errorf("package sets pinning the same version conflicted: %v", err) + } + err := validateTupleContributionsV1(records, contract, target, tuple("conflicting")) + if err == nil || !strings.Contains(err.Error(), "conflict on package") { + t.Errorf("package pin conflict error = %v", err) + } + + // A package set reference that resolves to another record type fails. + mistyped := composeTestTargetV1() + mistyped.Payloads = []RecordReferenceV1{} + mistyped.Selections = []TargetSelectionV1{} + mistyped.PackageSets = []RecordReferenceV1{recordTestReference(contract.ID)} + if err := validateTupleContributionsV1(records, contract, mistyped, tuple("")); err == nil { + t.Error("a release contract was accepted as a package set") + } + + // Two contributions claiming one export name with different paths conflict. + exporting := composeTestTargetV1() + exporting.Payloads = []RecordReferenceV1{} + exporting.PackageSets = []RecordReferenceV1{} + exporting.Exports = []ToolExportV1{{Name: "demo", Path: "/opt/demo/bin/other"}} + exporting.Selections = []TargetSelectionV1{} + err = validateTupleContributionsV1(records, contract, exporting, + supportTupleV1{Context: "build", Selections: []string{}, Parameters: []ParameterValueV1{}}) + if err == nil || !strings.Contains(err.Error(), "conflict on export") { + t.Errorf("export path conflict error = %v", err) + } +} + +// A request that omits selections normalizes to the contract defaults, so a +// target that advertises a subset excluding a default has no mapping to +// traverse for the request every consumer makes by default. +func TestTargetMustAdvertiseContractDefaultSelectionsV1(t *testing.T) { + contract := composeRichContractV1() + contract.Selections = SelectionRequestV1{ + Options: []string{"chromium", "firefox"}, Minimum: "1", Maximum: "1", + Defaults: []string{"chromium"}, CompatibilityGroups: [][]string{{"chromium", "firefox"}}, + } + target := composeRichTargetV1() + + if err := validateTargetAgainstContractV1(contract, target); err != nil { + t.Errorf("a target advertising both selections was rejected: %v", err) + } + + // Advertising only the default is a legal subset. + narrowed := composeRichTargetV1() + narrowed.Selections = narrowed.Selections[:1] + if err := validateTargetAgainstContractV1(contract, narrowed); err != nil { + t.Errorf("a target advertising only the default was rejected: %v", err) + } + + // Dropping the default is not, even though the remaining selection is valid. + withoutDefault := composeRichTargetV1() + withoutDefault.Selections = withoutDefault.Selections[1:] + err := validateTargetAgainstContractV1(contract, withoutDefault) + if err == nil || !strings.Contains(err.Error(), "default selection") { + t.Errorf("target omitting the contract default selection error = %v", err) + } +} + +// The binding contract publishes constituent metadata to consumers, so an +// artifact must bundle what the contract advertises at the advertised version +// and path. +func TestBindingArtifactBundledComponentsMatchTheContractV1(t *testing.T) { + contract := func() *BindingContractV1 { + return &BindingContractV1{Name: "python", Package: "demo", + Requirements: []string{"demo==1.2.3"}, SupportedPython: []string{"3.11"}, + BundledComponents: []BundledComponentV1{{Name: "nodejs", Version: "24.0.0", Path: "node"}}} + } + artifact := func(components ...BundledComponentV1) *BindingArtifactRecordV1 { + return &BindingArtifactRecordV1{ID: "artifact", + Filename: "demo-1.2.3-py3-none-manylinux1_x86_64.whl", RequiresPython: ">=3.11", + BundledComponents: components} + } + + if err := validateBindingArtifactAgainstContractV1(contract(), + artifact(BundledComponentV1{Name: "nodejs", Version: "24.0.0", Path: "node"})); err != nil { + t.Errorf("an artifact bundling exactly what the contract advertises was rejected: %v", err) + } + + err := validateBindingArtifactAgainstContractV1(contract(), artifact()) + if err == nil || !strings.Contains(err.Error(), "does not bundle") { + t.Errorf("missing bundled component error = %v", err) + } + err = validateBindingArtifactAgainstContractV1(contract(), + artifact(BundledComponentV1{Name: "nodejs", Version: "23.0.0", Path: "node"})) + if err == nil || !strings.Contains(err.Error(), "contract advertises") { + t.Errorf("bundled component version mismatch error = %v", err) + } + err = validateBindingArtifactAgainstContractV1(contract(), + artifact(BundledComponentV1{Name: "nodejs", Version: "24.0.0", Path: "vendor/node"})) + if err == nil || !strings.Contains(err.Error(), "contract advertises") { + t.Errorf("bundled component path mismatch error = %v", err) + } + + // An artifact may bundle more than the contract advertises: the contract + // publishes a floor of constituent metadata, not an exhaustive inventory. + if err := validateBindingArtifactAgainstContractV1(contract(), + artifact(BundledComponentV1{Name: "nodejs", Version: "24.0.0", Path: "node"}, + BundledComponentV1{Name: "playwright-core", Version: "1.2.3", Path: "package"})); err != nil { + t.Errorf("an artifact bundling an extra component was rejected: %v", err) + } +} + +// With optional selections and a declared default, a fixture that omits its +// selections normalizes to the default, so the empty set is not a reachable +// tuple and must not be enumerated as one. +func TestOptionalSelectionsWithDefaultsAreCoverableV1(t *testing.T) { + contract := composeRichContractV1() + contract.Selections = SelectionRequestV1{ + Options: []string{"chromium", "firefox"}, Minimum: "0", Maximum: "1", + Defaults: []string{"chromium"}, CompatibilityGroups: [][]string{{"chromium", "firefox"}}, + } + target := composeRichTargetV1() + records := composeTestRecordsV1() + + tuples, err := targetSupportTuplesV1(contract, target) + if err != nil { + t.Fatal(err) + } + for _, tuple := range tuples { + if len(tuple.Selections) == 0 { + t.Errorf("enumerated an empty selection tuple no fixture can normalize to: %+v", tuple) + } + } + + // Both advertised selections are coverable, and coverage now succeeds. + chromium := composeFixtureV1("debian-12-amd64-chromium", "python", "chromium") + firefox := composeFixtureV1("debian-12-amd64-firefox", "python", "firefox") + if err := validateTargetFixtureCoverageV1(records, contract, target, + []*IntegrationFixtureRecordV1{chromium, firefox}); err != nil { + t.Errorf("optional selections with a default were uncoverable: %v", err) + } + + // A fixture that omits its selections normalizes to the default and so + // covers the chromium tuple rather than a distinct empty one. + implicit := composeFixtureV1("debian-12-amd64-default", "python") + if err := validateTargetFixtureCoverageV1(records, contract, target, + []*IntegrationFixtureRecordV1{implicit, firefox}); err != nil { + t.Errorf("a fixture relying on the default selection did not cover its tuple: %v", err) + } + + // With no defaults declared, the empty set is reachable and must be covered. + noDefaults := composeRichContractV1() + noDefaults.Selections = SelectionRequestV1{ + Options: []string{"chromium", "firefox"}, Minimum: "0", Maximum: "1", + Defaults: []string{}, CompatibilityGroups: [][]string{{"chromium", "firefox"}}, + } + tuples, err = targetSupportTuplesV1(noDefaults, target) + if err != nil { + t.Fatal(err) + } + empty := false + for _, tuple := range tuples { + empty = empty || len(tuple.Selections) == 0 + } + if !empty { + t.Error("no defaults declared, so the empty selection set should be enumerated") + } +}