diff --git a/.changes/unreleased/+portable-python-root-compatibility.yaml b/.changes/unreleased/+portable-python-root-compatibility.yaml new file mode 100644 index 00000000..09baad39 --- /dev/null +++ b/.changes/unreleased/+portable-python-root-compatibility.yaml @@ -0,0 +1,2 @@ +kind: Fixed +body: Reject internally contradictory or over-complex portable Python package-root requirements before provider resolution. diff --git a/internal/portabletool/python.go b/internal/portabletool/python.go index 2679e30a..6686cda7 100644 --- a/internal/portabletool/python.go +++ b/internal/portabletool/python.go @@ -1,6 +1,7 @@ package portabletool import ( + "errors" "fmt" "regexp" "sort" @@ -12,6 +13,12 @@ import ( pep440 "github.com/aquasecurity/go-pep440-version" ) +const portableToolPythonMaxPackageRootSpecifiersV1 = portableToolCatalogMaxReferencesV1 + +// ErrPythonPackageRootCompatibilityUnprovenV1 marks a valid conjunction whose +// nonempty intersection cannot be established by the bounded local proof. +var ErrPythonPackageRootCompatibilityUnprovenV1 = errors.New("Python package root compatibility cannot be proven for the admitted PEP 440 constraints") + // PythonPackageRootDistributionNameV1 extracts the immutable direct // distribution identity from a binding requirement. func PythonPackageRootDistributionNameV1(requirement string) (string, error) { @@ -39,26 +46,70 @@ func PythonPackageRootDistributionNameV1(requirement string) (string, error) { return "", fmt.Errorf("Python package root requirement %q must not request extras", requirement) } if remainder != "" { + if strings.Count(remainder, ",")+1 > portableToolPythonMaxPackageRootSpecifiersV1 { + return "", fmt.Errorf("Python package root requirement must use at most %d version specifiers", portableToolPythonMaxPackageRootSpecifiersV1) + } + if strings.Contains(remainder, "||") { + return "", fmt.Errorf("invalid Python package root requirement %q", requirement) + } specifiers, err := pep440.NewSpecifiers(remainder) if err != nil || specifiers.String() != remainder { return "", fmt.Errorf("invalid Python package root requirement %q", requirement) } + for _, raw := range strings.Split(remainder, ",") { + if _, _, ok := portableToolPythonSplitVersionSpecifierV1(raw); !ok { + return "", fmt.Errorf("invalid Python package root requirement %q", requirement) + } + } } return portableToolPythonNormalizeDistributionNameV1(name), nil } -// PythonPackageRootRequirementsCompatibleV1 reports whether ordinary release -// constraints for one direct distribution have a nonempty intersection. -// Complex PEP 440 forms remain resolver authority and are ignored here, so -// this local check rejects only conjunctions it can prove unsatisfiable. -func PythonPackageRootRequirementsCompatibleV1(requirements []string) (bool, error) { - interval := portableToolPythonRequirementIntervalV1{ - lower: portableToolPythonRequirementBoundV1{version: []int{0}, inclusive: true, set: true}, +// ValidatePythonPackageRootRequirementsV1 requires one canonical package-root +// requirement per normalized distribution, matching binding-contract records. +func ValidatePythonPackageRootRequirementsV1(requirements []string) error { + if len(requirements) > portableToolCatalogMaxReferencesV1 { + return fmt.Errorf("Python package root requirements must use at most %d entries", portableToolCatalogMaxReferencesV1) } + distributions := make(map[string]string, len(requirements)) + for _, requirement := range requirements { + distribution, err := PythonPackageRootDistributionNameV1(requirement) + if err != nil { + return err + } + if previous, found := distributions[distribution]; found { + return fmt.Errorf("Python package root requirements %q and %q name the same distribution %q", previous, requirement, distribution) + } + distributions[distribution] = requirement + } + return nil +} + +// PythonPackageRootRequirementsCompatibleV1 reports whether constraints for +// one direct distribution have a nonempty intersection. Exact PEP 440 pins, +// including prerelease, local, and arbitrary equality, are checked against the +// complete conjunction. A valid form that the bounded interval model cannot +// represent fails closed unless an exact candidate proves the full conjunction. +func PythonPackageRootRequirementsCompatibleV1(requirements []string) (bool, error) { distribution := "" - excludedExact := make([][]int, 0) - excludedPrefixes := make([]portableToolPythonRequirementIntervalV1, 0) + uniqueRequirements := make([]string, 0, len(requirements)) + seenRequirements := make(map[string]struct{}, len(requirements)) + totalSpecifiers := 0 for _, requirement := range requirements { + if _, found := seenRequirements[requirement]; found { + continue + } + seenRequirements[requirement] = struct{}{} + trimmed := strings.TrimSpace(requirement) + name := portableToolPythonRequirementNamePatternV1.FindString(trimmed) + remainder := strings.TrimPrefix(trimmed, name) + if remainder != "" { + specifierCount := strings.Count(remainder, ",") + 1 + if specifierCount > portableToolPythonMaxPackageRootSpecifiersV1-totalSpecifiers { + return false, ErrPythonPackageRootCompatibilityUnprovenV1 + } + totalSpecifiers += specifierCount + } currentDistribution, err := PythonPackageRootDistributionNameV1(requirement) if err != nil { return false, err @@ -69,14 +120,71 @@ func PythonPackageRootRequirementsCompatibleV1(requirements []string) (bool, err return false, fmt.Errorf("Python package root requirements name different distributions %q and %q", distribution, currentDistribution) } + uniqueRequirements = append(uniqueRequirements, requirement) + } + if len(uniqueRequirements) == 0 { + return true, nil + } + + interval := portableToolPythonRequirementIntervalV1{ + lower: portableToolPythonRequirementBoundV1{version: []int{0}, inclusive: true, set: true}, + } + excludedExact := make([][]int, 0) + excludedPrefixes := make([]portableToolPythonRequirementIntervalV1, 0) + parsedRequirements := make([]pep440.Specifiers, 0, len(uniqueRequirements)) + parsedNonArbitrarySpecifiers := make([]pep440.Specifiers, 0, len(uniqueRequirements)) + proofCandidates := []pep440.Version{pep440.MustParse("0")} + arbitraryCandidate := "" + arbitraryCandidateSet := false + arbitraryCandidateConflict := false + hasNonArbitrarySpecifier := false + exactCandidate := "" + exactCandidatePriority := -1 + unrepresentedConstraint := false + for _, requirement := range uniqueRequirements { name := portableToolPythonRequirementNamePatternV1.FindString(requirement) remainder := strings.TrimPrefix(requirement, name) + if remainder != "" { + specifiers, err := pep440.NewSpecifiers(remainder) + if err != nil { + return false, fmt.Errorf("invalid Python package root requirement") + } + parsedRequirements = append(parsedRequirements, specifiers) + } for _, raw := range strings.Split(remainder, ",") { if raw == "" { continue } operator, expectedText, ok := portableToolPythonSplitVersionSpecifierV1(raw) - if !ok || operator == "===" { + if !ok { + return false, fmt.Errorf("invalid Python package root requirement") + } + if operator == "===" { + if !arbitraryCandidateSet { + arbitraryCandidate = expectedText + arbitraryCandidateSet = true + } else if !strings.EqualFold(arbitraryCandidate, expectedText) { + arbitraryCandidateConflict = true + } + } else { + hasNonArbitrarySpecifier = true + specifiers, err := pep440.NewSpecifiers(raw) + if err != nil { + return false, fmt.Errorf("invalid Python package root requirement") + } + parsedNonArbitrarySpecifiers = append(parsedNonArbitrarySpecifiers, specifiers) + } + if operator == "==" && !strings.HasSuffix(expectedText, ".*") { + priority := 0 + if strings.Contains(expectedText, "+") { + priority = 1 + } + if priority > exactCandidatePriority { + exactCandidate = expectedText + exactCandidatePriority = priority + } + } + if operator == "===" { continue } if strings.Contains(expectedText, "+") { @@ -85,15 +193,28 @@ func PythonPackageRootRequirementsCompatibleV1(requirements []string) (bool, err wildcard := strings.HasSuffix(expectedText, ".*") if wildcard { expectedText = strings.TrimSuffix(expectedText, ".*") + } else if operator != "===" { + if candidate, err := pep440.Parse(expectedText); err == nil { + proofCandidates = append(proofCandidates, candidate) + proofCandidates = append(proofCandidates, portableToolPythonProofSuccessorsV1(candidate)...) + } } expected, ok := portableToolPythonParseReleaseVersionV1(expectedText) if !ok { + unrepresentedConstraint = true continue } + if wildcard { + if successor, found := portableToolPythonNextPrefixV1(expected); found { + text := portableToolPythonReleaseVersionTextV1(successor) + proofCandidates = append(proofCandidates, portableToolPythonParseProofCandidatesV1(text+".dev0", text)...) + } + } switch operator { case "==": if wildcard { if !portableToolPythonConstrainPrefixV1(&interval, expected) { + unrepresentedConstraint = true continue } } else { @@ -108,6 +229,8 @@ func PythonPackageRootRequirementsCompatibleV1(requirements []string) (bool, err lower: portableToolPythonRequirementBoundV1{version: expected, inclusive: true, set: true}, upper: portableToolPythonRequirementBoundV1{version: upper, inclusive: false, set: true}, }) + } else { + unrepresentedConstraint = true } } else { excludedExact = append(excludedExact, expected) @@ -122,16 +245,66 @@ func PythonPackageRootRequirementsCompatibleV1(requirements []string) (bool, err portableToolPythonConstrainUpperV1(&interval, expected, false) case "~=": if len(expected) < 2 { + unrepresentedConstraint = true continue } portableToolPythonConstrainLowerV1(&interval, expected, true) - portableToolPythonConstrainPrefixV1(&interval, expected[:len(expected)-1]) + if !portableToolPythonConstrainPrefixV1(&interval, expected[:len(expected)-1]) { + unrepresentedConstraint = true + } } } } + if arbitraryCandidateSet { + if arbitraryCandidateConflict { + return false, nil + } + if !hasNonArbitrarySpecifier { + return true, nil + } + candidate, err := pep440.Parse(arbitraryCandidate) + if err != nil { + // An arbitrary literal outside the normalized release grammar + // cannot satisfy a normal PEP 440 specifier. + return false, nil + } + for _, specifiers := range parsedNonArbitrarySpecifiers { + if !specifiers.Check(candidate) { + return false, nil + } + } + return true, nil + } + if exactCandidatePriority >= 0 { + candidate, err := pep440.Parse(exactCandidate) + if err != nil { + return false, fmt.Errorf("invalid exact Python package version") + } + for _, specifiers := range parsedRequirements { + if !specifiers.Check(candidate) { + return false, nil + } + } + return true, nil + } if portableToolPythonIntervalEmptyV1(interval) { return false, nil } + if unrepresentedConstraint { + for _, candidate := range proofCandidates { + compatible := true + for _, specifiers := range parsedRequirements { + if !specifiers.Check(candidate) { + compatible = false + break + } + } + if compatible { + return true, nil + } + } + return false, ErrPythonPackageRootCompatibilityUnprovenV1 + } if portableToolPythonIntervalSingletonV1(interval) { for _, excluded := range excludedExact { if portableToolPythonCompareReleaseVersionsV1(interval.lower.version, excluded) == 0 { @@ -262,6 +435,45 @@ func portableToolPythonIntervalCoveredV1(allowed portableToolPythonRequirementIn var portableToolPythonRequirementNamePatternV1 = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*`) +var portableToolPythonVersionPhasePatternV1 = regexp.MustCompile(`^(.*(?:a|b|rc|\.post|\.dev))([0-9]+)$`) + +func portableToolPythonProofSuccessorsV1(version pep440.Version) []pep440.Version { + canonical := version.String() + nextRelease := portableToolPythonParseProofCandidatesV1(version.BaseVersion()+".1.dev0", version.BaseVersion()+".1") + phase := portableToolPythonVersionPhasePatternV1.FindStringSubmatch(canonical) + if phase == nil { + return nextRelease + } + serial, err := strconv.ParseUint(phase[2], 10, 64) + if err != nil || serial == ^uint64(0) { + return nextRelease + } + successor := phase[1] + strconv.FormatUint(serial+1, 10) + if strings.HasSuffix(phase[1], ".dev") { + return append(portableToolPythonParseProofCandidatesV1(successor), nextRelease...) + } + return append(portableToolPythonParseProofCandidatesV1(successor+".dev0", successor), nextRelease...) +} + +func portableToolPythonParseProofCandidatesV1(values ...string) []pep440.Version { + result := make([]pep440.Version, 0, len(values)) + for _, value := range values { + candidate, err := pep440.Parse(value) + if err == nil { + result = append(result, candidate) + } + } + return result +} + +func portableToolPythonReleaseVersionTextV1(version []int) string { + parts := make([]string, len(version)) + for index, component := range version { + parts[index] = strconv.Itoa(component) + } + return strings.Join(parts, ".") +} + func portableToolPythonValidRequirementIdentifierV1(value string) bool { if value == "" || !portableToolPythonASCIIAlphaNumericV1(value[0]) || !portableToolPythonASCIIAlphaNumericV1(value[len(value)-1]) { return false diff --git a/internal/portabletool/record_validate.go b/internal/portabletool/record_validate.go index 73247e0f..42df9cdf 100644 --- a/internal/portabletool/record_validate.go +++ b/internal/portabletool/record_validate.go @@ -19,6 +19,10 @@ import ( const portableToolCatalogMaxReferencesV1 = 1024 +// RecordArrayMaxEntriesV1 is the shared per-record array ceiling applied by +// strict portable-tool records and their provider-owned projections. +const RecordArrayMaxEntriesV1 = portableToolCatalogMaxReferencesV1 + type portableToolCatalogBundledComponentV1 = BundledComponentV1 type portableToolCatalogExportV1 = ToolExportV1 type portableToolCatalogBindingContractV1 = BindingContractV1 @@ -63,6 +67,119 @@ func ValidateRecordEnvelopeV1(record canonical.Envelope) error { return validatePortableRecordID(id) } +// ValidateBindingRecordReferencesV1 validates the exact contract and artifact +// references carried by a projected Python binding. The references must use +// their canonical record categories and name the same release and binding. +func ValidateBindingRecordReferencesV1(contract, artifact RecordReferenceV1) error { + if err := validatePortableToolRecordReferenceV1(contract); err != nil { + return fmt.Errorf("binding contract reference: %w", err) + } + if err := validatePortableToolRecordReferenceV1(artifact); err != nil { + return fmt.Errorf("binding artifact reference: %w", err) + } + contractSegments := strings.Split(contract.ID, "/") + if len(contractSegments) != 6 || contractSegments[3] != "bindings" || + validatePortableRecordIdentifier("binding", contractSegments[4]) != nil || contractSegments[5] != "contract" { + return fmt.Errorf("binding contract reference %q does not use the canonical binding-contract namespace", contract.ID) + } + artifactSegments := strings.Split(artifact.ID, "/") + if len(artifactSegments) != 7 || artifactSegments[3] != "bindings" || + validatePortableRecordIdentifier("binding", artifactSegments[4]) != nil || artifactSegments[5] != "artifacts" || + !portableToolCatalogPlatformLeafV1(artifactSegments[6]) { + return fmt.Errorf("binding artifact reference %q does not use the canonical binding-artifact namespace", artifact.ID) + } + if strings.Join(contractSegments[:5], "/") != strings.Join(artifactSegments[:5], "/") { + return fmt.Errorf("binding contract and artifact references do not name the same release binding") + } + return nil +} + +// ValidateBindingArtifactReferencePlatformV1 requires a canonical binding +// artifact reference to name the same target as its projected wheel metadata. +func ValidateBindingArtifactReferencePlatformV1(artifact RecordReferenceV1, platform string) error { + if err := validatePortableToolRecordReferenceV1(artifact); err != nil { + return fmt.Errorf("binding artifact reference: %w", err) + } + if !portableToolCatalogPlatformV1(platform) { + return fmt.Errorf("binding artifact platform %q is unsupported", platform) + } + segments := strings.Split(artifact.ID, "/") + expectedPlatform := strings.ReplaceAll(platform, "/", "-") + if len(segments) != 7 || segments[1] != "releases" || validatePortableToolVersionSegment(segments[2]) != nil || + segments[3] != "bindings" || validatePortableRecordIdentifier("binding", segments[4]) != nil || + segments[5] != "artifacts" || segments[6] != expectedPlatform { + return fmt.Errorf("binding artifact reference %q does not match platform %q", artifact.ID, platform) + } + return nil +} + +// ValidateBindingWheelTagsForPlatformV1 applies the strict artifact-record +// platform check to every exact wheel tag without imposing provider runtime +// eligibility policy on the tag's Python or ABI fields. +func ValidateBindingWheelTagsForPlatformV1(tags []string, platform string) error { + if !portableToolCatalogPlatformV1(platform) { + return fmt.Errorf("binding artifact platform %q is unsupported", platform) + } + for _, tag := range tags { + segments := strings.Split(tag, "-") + if len(segments) != 3 || !portableToolCatalogWheelTagGroupV1(segments[0]) || !portableToolCatalogWheelTagGroupV1(segments[1]) || !portableToolCatalogWheelTagGroupV1(segments[2]) { + return fmt.Errorf("binding artifact wheel tag %q is invalid", tag) + } + if _, err := ProjectWheelPlatformForTargetV1(segments[2], platform); err != nil { + return fmt.Errorf("binding artifact wheel tag %q is incompatible with platform %q", tag, platform) + } + } + return nil +} + +// ValidateBindingBundledComponentAgreementV1 requires every bundled component +// declared by a contract to be present with the same version and path in its +// selected artifact. Artifact-only inventory entries remain exact artifact +// metadata and are permitted. +func ValidateBindingBundledComponentAgreementV1(contract, artifact []BundledComponentV1) error { + bundled := make(map[string]BundledComponentV1, len(artifact)) + for _, component := range artifact { + bundled[component.Name] = component + } + for _, declared := range contract { + 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 || present.Path != declared.Path { + return fmt.Errorf("bundled component %q does not match the contract", declared.Name) + } + } + return nil +} + +// ValidateBindingCLIExportV1 applies the canonical name and absolute non-root +// slash-path grammar shared by binding contracts and provider projections. +func ValidateBindingCLIExportV1(export ToolExportV1) error { + if validatePortableRecordIdentifier("binding CLI", export.Name) != nil || validatePortableToolCatalogAbsolutePathV1(export.Path) != nil { + return fmt.Errorf("binding CLI must use a canonical name and absolute path") + } + return nil +} + +// ValidatePythonRequiresPythonV1 requires the canonical PEP 440 spelling used +// by exact binding artifact records and provider projections. +func ValidatePythonRequiresPythonV1(value string) error { + if strings.ContainsRune(value, '|') { + return fmt.Errorf("binding artifact requires_python must be a canonical PEP 440 specifier set") + } + for _, raw := range strings.Split(value, ",") { + if _, _, ok := portableToolPythonSplitVersionSpecifierV1(raw); !ok { + return fmt.Errorf("binding artifact requires_python must be a canonical PEP 440 specifier set") + } + } + specifiers, err := pep440.NewSpecifiers(value) + if value == "" || err != nil || specifiers.String() != value { + return fmt.Errorf("binding artifact requires_python must be a canonical PEP 440 specifier set") + } + return nil +} + func validatePortableToolCatalogReleaseManifestV1(value canonical.Object) error { const schema = ReleaseManifestSchemaV1 var record ReleaseManifestV1 @@ -164,22 +281,14 @@ func validatePortableToolCatalogBindingContractV1(value canonical.Object) error if len(segments) != 6 || segments[1] != "releases" || segments[3] != "bindings" || segments[4] != record.Name || segments[5] != "contract" || validatePortableToolVersionSegment(segments[2]) != nil { return fmt.Errorf("binding contract ID must use tool:/releases//bindings/%s/contract", record.Name) } - if validatePortableRecordIdentifier("binding CLI", record.CLI.Name) != nil || validatePortableToolCatalogAbsolutePathV1(record.CLI.Path) != nil { - return fmt.Errorf("binding CLI must use a canonical name and absolute path") + if err := ValidateBindingCLIExportV1(record.CLI); err != nil { + return err } if err := validatePortableToolCatalogSortedStringsV1("binding requirements", record.Requirements, true); err != nil { return err } - distributions := make(map[string]string, len(record.Requirements)) - for _, requirement := range record.Requirements { - distribution, err := PythonPackageRootDistributionNameV1(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 := ValidatePythonPackageRootRequirementsV1(record.Requirements); err != nil { + return fmt.Errorf("binding requirements: %w", err) } if err := validatePortableToolSupportedPythonClaimsV1(record.SupportedPython); err != nil { return err @@ -267,11 +376,11 @@ func validatePortableToolCatalogBindingArtifactV1(value canonical.Object) error if record.Contract.ID != expectedContract { return fmt.Errorf("binding artifact contract reference must be %q", expectedContract) } - if err := validatePortableToolCatalogBindingCompatibilityV1(record); err != nil { + filename, err := validatePortableToolCatalogBindingCompatibilityV1(record) + if err != nil { return err } - filenameParts := strings.Split(strings.TrimSuffix(record.Filename, ".whl"), "-") - if len(filenameParts) < 2 || filenameParts[0] != strings.ReplaceAll(portableToolPythonNormalizeDistributionNameV1(record.Name), "-", "_") || filenameParts[1] != record.EcosystemVersion { + if filename.Distribution != portableToolPythonNormalizeDistributionNameV1(record.Name) || filename.EcosystemVersion != record.EcosystemVersion { return fmt.Errorf("binding artifact name and ecosystem version must match the wheel filename %q", record.Filename) } if err := validatePortableToolCatalogDecimalV1("binding artifact size", record.Size, true); err != nil { @@ -515,50 +624,53 @@ func decodePortableToolCatalogRecordV1(value canonical.Object, schema string, fi return nil } -func validatePortableToolCatalogBindingCompatibilityV1(record portableToolCatalogBindingArtifactV1) error { +func validatePortableToolCatalogBindingCompatibilityV1(record portableToolCatalogBindingArtifactV1) (WheelFilenameProjectionV1, error) { if err := validatePortableToolCatalogSortedStringsV1("binding artifact tags", record.Tags, true); err != nil { - return err + return WheelFilenameProjectionV1{}, err } - filenameTags, err := portableToolCatalogWheelFilenameTagsV1(record.Filename) + filename, err := ProjectWheelFilenameV1(record.Filename) if err != nil { - return fmt.Errorf("binding artifact filename: %w", err) + return WheelFilenameProjectionV1{}, fmt.Errorf("binding artifact filename: %w", err) } - if !portableToolCatalogStringsEqualV1(filenameTags, record.Tags) { - return fmt.Errorf("binding artifact tags must exactly match the expanded wheel filename tags") + if !portableToolCatalogStringsEqualV1(filename.Tags, record.Tags) { + return WheelFilenameProjectionV1{}, fmt.Errorf("binding artifact tags must exactly match the expanded wheel filename tags") } - for _, tag := range record.Tags { - segments := strings.Split(tag, "-") - if len(segments) != 3 || !portableToolCatalogWheelTagGroupV1(segments[0]) || !portableToolCatalogWheelTagGroupV1(segments[1]) || !portableToolCatalogWheelTagGroupV1(segments[2]) { - return fmt.Errorf("binding artifact wheel tag %q is invalid", tag) - } - if !portableToolCatalogWheelPlatformCompatibleV1(segments[2], record.Platform) { - return fmt.Errorf("binding artifact wheel tag %q is incompatible with platform %q", tag, record.Platform) - } + if err := ValidateBindingWheelTagsForPlatformV1(record.Tags, record.Platform); err != nil { + return WheelFilenameProjectionV1{}, err } - specifiers, err := pep440.NewSpecifiers(record.RequiresPython) - if err != nil || specifiers.String() != record.RequiresPython { - return fmt.Errorf("binding artifact requires_python must be a canonical PEP 440 specifier set") + if err := ValidatePythonRequiresPythonV1(record.RequiresPython); err != nil { + return WheelFilenameProjectionV1{}, err } - return nil + return filename, nil } -func portableToolCatalogWheelFilenameTagsV1(filename string) ([]string, error) { +// WheelFilenameProjectionV1 is the canonical identity and expanded +// compatibility-tag projection of one wheel filename. +type WheelFilenameProjectionV1 struct { + Distribution string + EcosystemVersion string + Tags []string +} + +// ProjectWheelFilenameV1 parses one canonical wheel filename without reading +// the wheel or applying runtime eligibility policy. +func ProjectWheelFilenameV1(filename string) (WheelFilenameProjectionV1, error) { if !strings.HasSuffix(filename, ".whl") { - return nil, fmt.Errorf("wheel filename must end in .whl") + return WheelFilenameProjectionV1{}, 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") + return WheelFilenameProjectionV1{}, fmt.Errorf("wheel filename must contain distribution, version, Python, ABI, and platform tags") } if !portableToolCatalogWheelDistributionV1(parts[0]) { - return nil, fmt.Errorf("wheel filename contains an invalid distribution or version") + return WheelFilenameProjectionV1{}, 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") + return WheelFilenameProjectionV1{}, fmt.Errorf("wheel filename contains an invalid distribution or version") } if len(parts) == 6 && !portableToolCatalogWheelBuildTagV1(parts[2]) { - return nil, fmt.Errorf("wheel filename contains an invalid build tag") + return WheelFilenameProjectionV1{}, fmt.Errorf("wheel filename contains an invalid build tag") } groups := [][]string{ strings.Split(parts[len(parts)-3], "."), strings.Split(parts[len(parts)-2], "."), strings.Split(parts[len(parts)-1], "."), @@ -566,12 +678,12 @@ func portableToolCatalogWheelFilenameTagsV1(filename string) ([]string, error) { count := 1 for _, group := range groups { if len(group) == 0 || len(group) > portableToolCatalogMaxReferencesV1/count { - return nil, fmt.Errorf("wheel filename expands to more than %d compatibility tags", portableToolCatalogMaxReferencesV1) + return WheelFilenameProjectionV1{}, fmt.Errorf("wheel filename expands to more than %d compatibility tags", portableToolCatalogMaxReferencesV1) } count *= len(group) for _, component := range group { if !portableToolCatalogWheelTagComponentV1(component) { - return nil, fmt.Errorf("wheel filename contains an invalid compatibility tag") + return WheelFilenameProjectionV1{}, fmt.Errorf("wheel filename contains an invalid compatibility tag") } } } @@ -586,10 +698,14 @@ func portableToolCatalogWheelFilenameTagsV1(filename string) ([]string, error) { 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 WheelFilenameProjectionV1{}, fmt.Errorf("wheel filename compatibility tags must be unique") } } - return tags, nil + return WheelFilenameProjectionV1{ + Distribution: portableToolPythonNormalizeDistributionNameV1(parts[0]), + EcosystemVersion: parts[1], + Tags: tags, + }, nil } func validatePortableToolCatalogProbeV1(probe portableToolCatalogProbeV1) error { @@ -787,11 +903,6 @@ func portableToolCatalogWheelTagComponentV1(value string) bool { return true } -func portableToolCatalogWheelPlatformCompatibleV1(tag, platform string) bool { - _, err := ProjectWheelPlatformForTargetV1(tag, platform) - return err == nil -} - // WheelPlatformProjectionV1 is the single portable-tool wheel platform // policy projection. It deliberately contains only the policy facts needed // by record validation and provider runtime guards; pip remains the runtime diff --git a/internal/portabletool/record_validate_test.go b/internal/portabletool/record_validate_test.go index e091f2e3..e59106bd 100644 --- a/internal/portabletool/record_validate_test.go +++ b/internal/portabletool/record_validate_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "testing" "github.com/omry/reploy/internal/canonical" @@ -67,6 +68,160 @@ func TestValidateRecordEnvelopeV1RejectsNoncanonicalRecordID(t *testing.T) { } } +func TestValidateBindingRecordReferencesV1(t *testing.T) { + t.Parallel() + digest := canonical.Digest("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + contract := portabletool.RecordReferenceV1{ + ID: "tool:demo/releases/1.0.0/bindings/python/contract", Digest: digest, + } + artifact := portabletool.RecordReferenceV1{ + ID: "tool:demo/releases/1.0.0/bindings/python/artifacts/linux-amd64", Digest: digest, + } + if err := portabletool.ValidateBindingRecordReferencesV1(contract, artifact); err != nil { + t.Fatalf("valid binding references: %v", err) + } + for _, test := range []struct { + name string + contract portabletool.RecordReferenceV1 + artifact portabletool.RecordReferenceV1 + }{ + {name: "malformed contract", contract: portabletool.RecordReferenceV1{ID: "contract", Digest: digest}, artifact: artifact}, + {name: "swapped categories", contract: artifact, artifact: contract}, + {name: "different release binding", contract: contract, artifact: portabletool.RecordReferenceV1{ID: "tool:other/releases/1.0.0/bindings/python/artifacts/linux-amd64", Digest: digest}}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if err := portabletool.ValidateBindingRecordReferencesV1(test.contract, test.artifact); err == nil { + t.Fatal("invalid binding references were accepted") + } + }) + } +} + +func TestValidateBindingArtifactReferencePlatformV1(t *testing.T) { + t.Parallel() + digest := canonical.Digest("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + artifact := portabletool.RecordReferenceV1{ + ID: "tool:demo/releases/1.0.0/bindings/python/artifacts/linux-amd64", Digest: digest, + } + if err := portabletool.ValidateBindingArtifactReferencePlatformV1(artifact, "linux/amd64"); err != nil { + t.Fatalf("matching artifact platform: %v", err) + } + if err := portabletool.ValidateBindingArtifactReferencePlatformV1(artifact, "linux/arm64"); err == nil { + t.Fatal("mismatched artifact platform was accepted") + } + artifact.ID = "tool:demo/releases/1.0.0/bindings/python/artifacts/linux-arm64" + if err := portabletool.ValidateBindingArtifactReferencePlatformV1(artifact, "linux/arm64"); err != nil { + t.Fatalf("matching ARM64 artifact platform: %v", err) + } +} + +func TestValidateBindingWheelTagsForPlatformV1(t *testing.T) { + t.Parallel() + if err := portabletool.ValidateBindingWheelTagsForPlatformV1([]string{ + "py3-cp313-manylinux1_x86_64", + "py3-none-manylinux1_x86_64", + }, "linux/amd64"); err != nil { + t.Fatalf("matching exact tags: %v", err) + } + if err := portabletool.ValidateBindingWheelTagsForPlatformV1( + []string{"py3-none-manylinux_2_17_aarch64"}, "linux/amd64", + ); err == nil { + t.Fatal("incompatible exact tag platform was accepted") + } +} + +func TestValidatePythonPackageRootRequirementsV1RejectsDuplicateNormalizedRoots(t *testing.T) { + t.Parallel() + if err := portabletool.ValidatePythonPackageRootRequirementsV1([]string{"demo==1", "dependency>=2"}); err != nil { + t.Fatalf("distinct package roots: %v", err) + } + if err := portabletool.ValidatePythonPackageRootRequirementsV1([]string{"Demo==1", "demo>=1"}); err == nil { + t.Fatal("duplicate normalized package roots were accepted") + } + for _, requirement := range []string{"demo=1", "demo>=2||==1", "demo>=2||=1,==1"} { + if err := portabletool.ValidatePythonPackageRootRequirementsV1([]string{requirement}); err == nil { + t.Errorf("non-PEP package root %q was accepted", requirement) + } + } +} + +func TestValidateBindingBundledComponentAgreementV1(t *testing.T) { + t.Parallel() + declared := []portabletool.BundledComponentV1{{Name: "runtime", Version: "1.0.0", Path: "demo/runtime"}} + if err := portabletool.ValidateBindingBundledComponentAgreementV1(declared, append([]portabletool.BundledComponentV1{}, declared...)); err != nil { + t.Fatalf("matching bundled component: %v", err) + } + if err := portabletool.ValidateBindingBundledComponentAgreementV1(declared, nil); err == nil { + t.Fatal("missing bundled component was accepted") + } + mismatched := append([]portabletool.BundledComponentV1{}, declared...) + mismatched[0].Version = "2.0.0" + if err := portabletool.ValidateBindingBundledComponentAgreementV1(declared, mismatched); err == nil { + t.Fatal("mismatched bundled component was accepted") + } +} + +func TestValidateBindingCLIExportV1(t *testing.T) { + t.Parallel() + if err := portabletool.ValidateBindingCLIExportV1(portabletool.ToolExportV1{Name: "demo-cli", Path: "/opt/demo/bin/demo"}); err != nil { + t.Fatalf("valid binding CLI: %v", err) + } + for _, export := range []portabletool.ToolExportV1{ + {Name: "bad_name", Path: "/opt/demo/bin/demo"}, + {Name: "demo", Path: "/"}, + {Name: "demo", Path: "/opt/../demo"}, + {Name: "demo", Path: `/opt\demo`}, + {Name: "demo", Path: "/opt/demo\n"}, + } { + if err := portabletool.ValidateBindingCLIExportV1(export); err == nil { + t.Errorf("invalid binding CLI %#v was accepted", export) + } + } +} + +func TestValidatePythonRequiresPythonV1(t *testing.T) { + t.Parallel() + for _, value := range []string{ + ">=3.12,<3.13", + "==3.12.*", + "~=3.12.0", + "===v1.0", + "===v1.0,==1", + } { + if err := portabletool.ValidatePythonRequiresPythonV1(value); err != nil { + t.Errorf("canonical requires_python %q: %v", value, err) + } + } + for _, value := range []string{ + "", + " >=3.12,<3.13 ", + ">=3.12, <3.13", + "not-a-specifier", + "3.12", + "=3.12", + ">=3.12||<2", + } { + if err := portabletool.ValidatePythonRequiresPythonV1(value); err == nil { + t.Errorf("noncanonical requires_python %q was accepted", value) + } + } +} + +func TestValidateRecordEnvelopeV1RejectsNonPEPRequiresPythonExtensions(t *testing.T) { + t.Parallel() + for _, requiresPython := range []string{"3.12", "=3.12", ">=3.12||<2"} { + value := readDefinitionObjectV1(t, "playwright/releases/1.61.0/bindings/python/linux-amd64.json") + value["requires_python"] = requiresPython + if err := portabletool.ValidateRecordEnvelopeV1(canonical.Envelope{ + Schema: portabletool.BindingArtifactSchemaV1, + Value: value, + }); err == nil { + t.Errorf("binding artifact requires_python %q was accepted", requiresPython) + } + } +} + func TestValidateRecordEnvelopeV1RequiresNormalizedSupportedPythonClaims(t *testing.T) { t.Parallel() for _, claims := range [][]any{ @@ -195,6 +350,20 @@ func TestProjectWheelPlatformV1UsesOneBoundedManylinuxPolicy(t *testing.T) { } } +func TestProjectWheelFilenameV1ReturnsCanonicalIdentityAndExpandedTags(t *testing.T) { + t.Parallel() + projection, err := portabletool.ProjectWheelFilenameV1("demo_pkg-1.2.0-py3.cp313-none-manylinux1_x86_64.whl") + if err != nil { + t.Fatal(err) + } + if projection.Distribution != "demo-pkg" || projection.EcosystemVersion != "1.2.0" || !reflect.DeepEqual(projection.Tags, []string{ + "cp313-none-manylinux1_x86_64", + "py3-none-manylinux1_x86_64", + }) { + t.Fatalf("wheel filename projection = %#v", projection) + } +} + func TestValidateRecordEnvelopeV1UsesWheelPlatformProjection(t *testing.T) { t.Parallel() value := readDefinitionObjectV1(t, "playwright/releases/1.61.0/bindings/python/linux-amd64.json") diff --git a/internal/providers/portable_tool_dag.go b/internal/providers/portable_tool_dag.go index 797ab57f..bf48f4d2 100644 --- a/internal/providers/portable_tool_dag.go +++ b/internal/providers/portable_tool_dag.go @@ -605,13 +605,6 @@ func validatePortableToolProviderSharedClaimsV1( } key := domain.PackageManager.ID + "\x00" + distribution pythonRequirements[key] = append(pythonRequirements[key], requirement) - compatible, err := PythonPackageRootRequirementsCompatibleV1(pythonRequirements[key]) - if err != nil { - return fmt.Errorf("binding contract requirements for %q: %w", distribution, err) - } - if !compatible { - return fmt.Errorf("portable tool provider shared-domain conflict on Python package %q", strings.ReplaceAll(key, "\x00", "/")) - } } supported, supportedPresent, err := portableToolStringListFieldV1(selected.Record.Value, "supported_python") if err != nil { @@ -671,6 +664,21 @@ func validatePortableToolProviderSharedClaimsV1( } } } + pythonRequirementKeys := make([]string, 0, len(pythonRequirements)) + for key := range pythonRequirements { + pythonRequirementKeys = append(pythonRequirementKeys, key) + } + sort.Strings(pythonRequirementKeys) + for _, key := range pythonRequirementKeys { + compatible, err := PythonPackageRootRequirementsCompatibleV1(pythonRequirements[key]) + if err != nil { + _, distribution, _ := strings.Cut(key, "\x00") + return fmt.Errorf("binding contract requirements for %q: %w", distribution, err) + } + if !compatible { + return fmt.Errorf("portable tool provider shared-domain conflict on Python package %q", strings.ReplaceAll(key, "\x00", "/")) + } + } return nil } diff --git a/internal/providers/portable_tool_dag_test.go b/internal/providers/portable_tool_dag_test.go index 30b3a606..8d057a12 100644 --- a/internal/providers/portable_tool_dag_test.go +++ b/internal/providers/portable_tool_dag_test.go @@ -435,6 +435,22 @@ func TestBuildPortableToolProviderDAGV1ComparesBindingPythonSemantics(t *testing if _, err := BuildPortableToolProviderDAGV1(portableToolProviderPlanFixtureV1(), plan, domains); err != nil { t.Fatalf("compatible Python constraints rejected: %v", err) } + for _, requirement := range []string{"demo>=1rc1,<2", "demo>1rc1,<2", "demo>=1!2", "demo===1.*", "demo===v1.0"} { + complexDuplicate := clonePortableToolPlanForTest(plan) + setPortableToolBindingPythonSemanticsV1(&complexDuplicate.Tools[0].Responsibilities.BindingContracts[0], []string{requirement}, []string{"3.12"}) + setPortableToolBindingPythonSemanticsV1(&complexDuplicate.Tools[1].Responsibilities.BindingContracts[0], []string{requirement}, []string{"3.12"}) + if _, err := BuildPortableToolProviderDAGV1(portableToolProviderPlanFixtureV1(), complexDuplicate, domains); err != nil { + t.Fatalf("duplicate complex Python requirement %q rejected: %v", requirement, err) + } + } + for _, requirement := range []string{"demo>=3,<3", "demo==1,==2", "demo==1.*,!=1.*", "demo===1.*,==1.*"} { + contradictory := clonePortableToolPlanForTest(plan) + setPortableToolBindingPythonSemanticsV1(&contradictory.Tools[0].Responsibilities.BindingContracts[0], []string{requirement}, []string{"3.12"}) + setPortableToolBindingPythonSemanticsV1(&contradictory.Tools[1].Responsibilities.BindingContracts[0], []string{requirement}, []string{"3.12"}) + if _, err := BuildPortableToolProviderDAGV1(portableToolProviderPlanFixtureV1(), contradictory, domains); err == nil { + t.Fatalf("contradictory Python requirement %q was accepted", requirement) + } + } mixedGranularity := clonePortableToolPlanForTest(plan) setPortableToolBindingPythonSemanticsV1(&mixedGranularity.Tools[0].Responsibilities.BindingContracts[0], []string{"demo>=1"}, []string{"3.12"}) setPortableToolBindingPythonSemanticsV1(&mixedGranularity.Tools[1].Responsibilities.BindingContracts[0], []string{"demo<3"}, []string{"3.12.7"}) diff --git a/internal/providers/python/package_request.go b/internal/providers/python/package_request.go index 8212df84..77d3deda 100644 --- a/internal/providers/python/package_request.go +++ b/internal/providers/python/package_request.go @@ -2,6 +2,7 @@ package python import ( "bytes" + "errors" "fmt" "sort" "strings" @@ -71,6 +72,10 @@ func PackageRootRequirementsCompatibleV1(requirements []string) (bool, error) { return portabletool.PythonPackageRootRequirementsCompatibleV1(requirements) } +func PackageRootCompatibilityUnprovenV1(err error) bool { + return errors.Is(err, portabletool.ErrPythonPackageRootCompatibilityUnprovenV1) +} + // ProviderRequestDistributionsV1 returns the normalized direct distribution // roots in one canonical Python provider request. It does not evaluate or // resolve dependencies. diff --git a/internal/providers/python/package_request_test.go b/internal/providers/python/package_request_test.go index 38f0d4c0..50bbeff5 100644 --- a/internal/providers/python/package_request_test.go +++ b/internal/providers/python/package_request_test.go @@ -116,6 +116,10 @@ func TestPackageRootDistributionNameV1Limits(t *testing.T) { if _, err := PackageRootDistributionNameV1(longSpecifier); err != nil { t.Errorf("long specifier set = %v", err) } + tooManySpecifiers := "demo" + strings.Repeat(">=1,", 1024) + ">=1" + if _, err := PackageRootDistributionNameV1(tooManySpecifiers); err == nil { + t.Error("over-limit specifier set succeeded") + } } func TestPackageRootDistributionNameV1NormalizesIdentically(t *testing.T) { @@ -144,8 +148,32 @@ func TestPackageRootRequirementsCompatibleV1(t *testing.T) { {name: "exact pin in excluded prefix", requirements: []string{"demo==1.2", "demo!=1.*"}, want: false}, {name: "excluded prefix", requirements: []string{"demo>=1", "demo<2", "demo!=1.*"}, want: false}, {name: "tiled excluded prefixes", requirements: []string{"demo>=1", "demo<3", "demo!=1.*", "demo!=2.*"}, want: false}, - {name: "unsupported form stays resolver authority", requirements: []string{"demo==1rc1", "demo==2rc1"}, want: true}, - {name: "local labels stay resolver authority", requirements: []string{"demo==1+abc", "demo!=1+def"}, want: true}, + {name: "conflicting prerelease pins", requirements: []string{"demo==1rc1", "demo==2rc1"}, want: false}, + {name: "matching prerelease pin and range", requirements: []string{"demo==1rc1", "demo>=1rc1,<2"}, want: true}, + {name: "single prerelease range", requirements: []string{"demo>=1rc1,<2"}, want: true}, + {name: "strict prerelease range", requirements: []string{"demo>1rc1,<2"}, want: true}, + {name: "narrow strict prerelease range", requirements: []string{"demo>1rc1,<1rc2"}, want: true}, + {name: "strict post release lower bounds", requirements: []string{"demo>1rc1", "demo>1.post1"}, want: true}, + {name: "strict prerelease outside excluded prefix", requirements: []string{"demo>1rc1", "demo!=1.*"}, want: true}, + {name: "duplicate prerelease range", requirements: []string{"demo>=1rc1,<2", "demo>=1rc1,<2"}, want: true}, + {name: "single epoch range", requirements: []string{"demo>=1!2"}, want: true}, + {name: "duplicate epoch range", requirements: []string{"demo>=1!2", "demo>=1!2"}, want: true}, + {name: "conflicting local pins", requirements: []string{"demo==1+abc", "demo==1+def"}, want: false}, + {name: "matching local pin and public equality", requirements: []string{"demo==1+abc", "demo==1"}, want: true}, + {name: "conflicting arbitrary equality pins", requirements: []string{"demo===1+abc", "demo===1+def"}, want: false}, + {name: "matching arbitrary and normalized equality", requirements: []string{"demo===1.0", "demo==1"}, want: true}, + {name: "matching arbitrary and differently spelled normalized equality", requirements: []string{"demo===1.0", "demo==1.0.0"}, want: true}, + {name: "arbitrary spelling conflicts with normalized local equality", requirements: []string{"demo===1", "demo==1+abc"}, want: false}, + {name: "arbitrary wildcard literal", requirements: []string{"demo===1.*"}, want: true}, + {name: "arbitrary noncanonical version literal", requirements: []string{"demo===v1.0"}, want: true}, + {name: "arbitrary noncanonical literal with normalized equality", requirements: []string{"demo===v1.0", "demo==1"}, want: true}, + {name: "matching arbitrary wildcard literals", requirements: []string{"demo===1.*", "demo===1.*"}, want: true}, + {name: "conflicting arbitrary wildcard literals", requirements: []string{"demo===1.*", "demo===2.*"}, want: false}, + {name: "conflicting arbitrary normalized spellings", requirements: []string{"demo===v1.0", "demo===1.0"}, want: false}, + {name: "arbitrary wildcard cannot satisfy normal prefix", requirements: []string{"demo===1.*", "demo==1.*"}, want: false}, + {name: "single contradictory range", requirements: []string{"demo>=3,<3"}, want: false}, + {name: "single contradictory exact pins", requirements: []string{"demo==1,==2"}, want: false}, + {name: "single fully excluded prefix", requirements: []string{"demo==1.*,!=1.*"}, want: false}, } { t.Run(testCase.name, func(t *testing.T) { compatible, err := PackageRootRequirementsCompatibleV1(testCase.requirements) @@ -160,6 +188,66 @@ func TestPackageRootRequirementsCompatibleV1(t *testing.T) { if _, err := PackageRootRequirementsCompatibleV1([]string{"demo>=1", "other<3"}); err == nil { t.Fatal("different distributions succeeded") } + maxComponent := fmt.Sprintf("%d", int(^uint(0)>>1)) + for _, requirements := range [][]string{ + {"demo=1"}, + {"demo>=2||==1"}, + {"demo>=2||=1,==1"}, + {"demo>=1!2", "demo<1!1"}, + {"demo>=2rc1", "demo<1rc1"}, + {"demo>=2rc1,<1rc1"}, + {"demo==" + maxComponent + ".*", "demo!=" + maxComponent + ".*"}, + } { + if compatible, err := PackageRootRequirementsCompatibleV1(requirements); err == nil || compatible { + t.Errorf("unrepresented constraints %q = compatible %t, error %v", requirements, compatible, err) + } + } + firstHalf := "demo" + strings.Repeat(">=1rc1,", 511) + ">=1rc1" + secondOverHalf := "demo" + strings.Repeat(">=1rc1,", 512) + ">=1rc2" + if compatible, err := PackageRootRequirementsCompatibleV1([]string{firstHalf, secondOverHalf}); err == nil || compatible { + t.Errorf("over-limit aggregate specifiers = compatible %t, error %v", compatible, err) + } +} + +func BenchmarkPackageRootRequirementsCompatibleV1DuplicateExactRoots(b *testing.B) { + requirements := make([]string, 1024) + for index := range requirements { + requirements[index] = "demo==1" + } + requirements[len(requirements)-1] = "demo===1.0" + b.ResetTimer() + for range b.N { + compatible, err := PackageRootRequirementsCompatibleV1(requirements) + if err != nil || !compatible { + b.Fatalf("compatible = %v, error = %v", compatible, err) + } + } +} + +func BenchmarkPackageRootRequirementsCompatibleV1PrereleaseSpecifierLimit(b *testing.B) { + requirement := "demo" + strings.Repeat(">=1rc1,", 1023) + ">=1rc1" + b.ResetTimer() + for range b.N { + compatible, err := PackageRootRequirementsCompatibleV1([]string{requirement}) + if err != nil || !compatible { + b.Fatalf("compatible = %v, error = %v", compatible, err) + } + } +} + +func BenchmarkPackageRootRequirementsCompatibleV1DuplicateWideRoots(b *testing.B) { + requirement := "demo" + strings.Repeat(">=1rc1,", 1023) + ">=1rc1" + requirements := make([]string, 1024) + for index := range requirements { + requirements[index] = requirement + } + b.ResetTimer() + for range b.N { + compatible, err := PackageRootRequirementsCompatibleV1(requirements) + if err != nil || !compatible { + b.Fatalf("compatible = %v, error = %v", compatible, err) + } + } } func TestProviderRequestDistributionsV1ReturnsSortedDirectRoots(t *testing.T) { diff --git a/internal/toolcatalog/records_compose.go b/internal/toolcatalog/records_compose.go index e7092fa3..d97036c1 100644 --- a/internal/toolcatalog/records_compose.go +++ b/internal/toolcatalog/records_compose.go @@ -9,6 +9,7 @@ import ( pep440 "github.com/aquasecurity/go-pep440-version" "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/canonical" + "github.com/omry/reploy/internal/portabletool" pythonprovider "github.com/omry/reploy/internal/providers/python" ) @@ -227,18 +228,8 @@ func validateBindingArtifactAgainstContractV1(contract *BindingContractV1, artif if err != nil { return fmt.Errorf("requires_python is invalid") } - 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 || present.Path != declared.Path { - return fmt.Errorf("bundled component %q does not match the contract", declared.Name) - } + if err := portabletool.ValidateBindingBundledComponentAgreementV1(contract.BundledComponents, artifact.BundledComponents); err != nil { + return err } claims, err := pythonprovider.NormalizeSupportedPythonClaimsV1(contract.SupportedPython) if err != nil { diff --git a/internal/toolcatalog/solver.go b/internal/toolcatalog/solver.go index 42409154..0d086bf7 100644 --- a/internal/toolcatalog/solver.go +++ b/internal/toolcatalog/solver.go @@ -549,6 +549,8 @@ func (catalog *CatalogV1) solveCandidateSetsV1(sets []orderedCandidateSetV1, selected := make([]ReleaseCandidateV1, 0, len(sets)) visited := 0 lastConflict := "" + bindingCompatibility := make(bindingCompatibilityCacheV1) + bindingDistributions := make(map[string]string) var search func(int) (bool, error) search = func(index int) (bool, error) { if index == len(sets) { @@ -560,7 +562,8 @@ func (catalog *CatalogV1) solveCandidateSetsV1(sets []orderedCandidateSetV1, return false, fmt.Errorf("joint assignment visited-state cap %d exceeded before a complete assignment", limit) } selected = append(selected, candidate) - conflict, err := catalog.assignmentConflictV1(sets, selected, domains, active) + conflict, err := catalog.assignmentConflictWithBindingCacheV1( + sets, selected, domains, active, bindingCompatibility, bindingDistributions) if err != nil { return false, err } @@ -611,8 +614,16 @@ type ownedPathClaimV1 struct { type bindingRequirementClaimV1 struct { owners []string requirements []string + seen map[string]struct{} } +type bindingCompatibilityResultV1 struct { + compatible bool + err error +} + +type bindingCompatibilityCacheV1 map[string]bindingCompatibilityResultV1 + type pythonInterpreterClaimV1 struct { owners []string constraints [][]string @@ -620,21 +631,33 @@ type pythonInterpreterClaimV1 struct { } type assignmentClaimsV1 struct { - semantic map[string]semanticClaimV1 - paths map[string][]ownedPathClaimV1 - installRoots map[string][]ownedPathClaimV1 - bindingRequirements map[string]bindingRequirementClaimV1 - pythonInterpreters map[string]pythonInterpreterClaimV1 + semantic map[string]semanticClaimV1 + paths map[string][]ownedPathClaimV1 + installRoots map[string][]ownedPathClaimV1 + bindingRequirements map[string]bindingRequirementClaimV1 + bindingDistributions map[string]string + pythonInterpreters map[string]pythonInterpreterClaimV1 } func (catalog *CatalogV1) assignmentConflictV1(sets []orderedCandidateSetV1, candidates []ReleaseCandidateV1, domains []ProviderDomainSetV1, active ActiveProviderConstraintsV1) (string, error) { + return catalog.assignmentConflictWithBindingCacheV1(sets, candidates, domains, active, nil, nil) +} + +func (catalog *CatalogV1) assignmentConflictWithBindingCacheV1(sets []orderedCandidateSetV1, + candidates []ReleaseCandidateV1, domains []ProviderDomainSetV1, + active ActiveProviderConstraintsV1, bindingCompatibility bindingCompatibilityCacheV1, + bindingDistributions map[string]string) (string, error) { + if bindingDistributions == nil { + bindingDistributions = make(map[string]string) + } claims := assignmentClaimsV1{ semantic: make(map[string]semanticClaimV1), paths: make(map[string][]ownedPathClaimV1), - installRoots: make(map[string][]ownedPathClaimV1), - bindingRequirements: make(map[string]bindingRequirementClaimV1), - pythonInterpreters: make(map[string]pythonInterpreterClaimV1), + installRoots: make(map[string][]ownedPathClaimV1), + bindingRequirements: make(map[string]bindingRequirementClaimV1), + bindingDistributions: bindingDistributions, + pythonInterpreters: make(map[string]pythonInterpreterClaimV1), } if conflict, err := catalog.addActiveProviderClaimsV1(&claims, domains, active); err != nil { return "", err @@ -647,13 +670,15 @@ func (catalog *CatalogV1) assignmentConflictV1(sets []orderedCandidateSetV1, ID: sets[index].group.Scope + "/" + sets[index].group.Tool + "@" + candidate.Manifest.Version + "~" + candidate.Manifest.Revision, }) - if conflict, err := catalog.addCandidateClaimsV1(&claims, sets[index].domains, owner, candidate); err != nil { + if conflict, err := catalog.addCandidateClaimsForAssignmentV1( + &claims, sets[index].domains, owner, candidate); err != nil { return "", err } else if conflict != "" { return conflict, nil } } - return "", nil + return validateBindingRequirementClaimsV1( + &claims, len(candidates) == len(sets), bindingCompatibility) } func constraintOwnerLabelV1(source ConstraintSourceV1) string { @@ -703,12 +728,8 @@ func (catalog *CatalogV1) addActiveProviderClaimsV1(claims *assignmentClaimsV1, } } for _, requirement := range binding.Requirements { - distribution, err := pythonprovider.PackageRootDistributionNameV1(requirement) - if err != nil { - return "", err - } - if conflict, err := addBindingRequirementClaimV1(claims, - domains.PackageManager, distribution, requirement, owner); err != nil { + if conflict, err := addBindingRequirementTextClaimV1( + claims, domains.PackageManager, requirement, owner); err != nil { return "", err } else if conflict != "" { return conflict, nil @@ -778,25 +799,77 @@ func addBindingRequirementClaimV1(claims *assignmentClaimsV1, domain string, if !exists { claims.bindingRequirements[claimKey] = bindingRequirementClaimV1{ owners: []string{owner}, requirements: []string{requirement}, + seen: map[string]struct{}{requirement: {}}, } return "", nil } - requirements := append(append([]string{}, previous.requirements...), requirement) - compatible, err := pythonprovider.PackageRootRequirementsCompatibleV1(requirements) - if err != nil { - return "", err - } - if !compatible { - return fmt.Sprintf("binding requirement conflict in domain %q on %q among %s", - domain, key, formatSourcedConstraintsV1( - append(append([]string{}, previous.owners...), owner), requirements)), nil + if _, duplicate := previous.seen[requirement]; duplicate { + return "", nil } - previous.requirements = requirements + previous.requirements = append(previous.requirements, requirement) previous.owners = append(previous.owners, owner) + previous.seen[requirement] = struct{}{} claims.bindingRequirements[claimKey] = previous return "", nil } +func addBindingRequirementTextClaimV1(claims *assignmentClaimsV1, domain string, + requirement string, owner string) (string, error) { + if claims.bindingDistributions == nil { + claims.bindingDistributions = make(map[string]string) + } + cacheKey := domain + "\x00" + requirement + distribution, found := claims.bindingDistributions[cacheKey] + if !found { + var err error + distribution, err = pythonprovider.PackageRootDistributionNameV1(requirement) + if err != nil { + return "", err + } + claims.bindingDistributions[cacheKey] = distribution + } + return addBindingRequirementClaimV1(claims, domain, distribution, requirement, owner) +} + +func validateBindingRequirementClaimsV1(claims *assignmentClaimsV1, complete bool, + compatibility bindingCompatibilityCacheV1) (string, error) { + keys := make([]string, 0, len(claims.bindingRequirements)) + for key := range claims.bindingRequirements { + keys = append(keys, key) + } + sort.Strings(keys) + for _, claimKey := range keys { + claim := claims.bindingRequirements[claimKey] + cacheKey := claimKey + "\x00" + strings.Join(claim.requirements, "\x00") + result, found := compatibility[cacheKey] + if !found { + result.compatible, result.err = pythonprovider.PackageRootRequirementsCompatibleV1(claim.requirements) + if compatibility != nil { + compatibility[cacheKey] = result + } + } + compatible, err := result.compatible, result.err + if err != nil { + if pythonprovider.PackageRootCompatibilityUnprovenV1(err) { + if !complete { + continue + } + domain, key, _ := strings.Cut(claimKey, "\x00") + return fmt.Sprintf("binding requirement compatibility in domain %q on %q cannot be proven among %s", + domain, key, formatSourcedConstraintsV1(claim.owners, claim.requirements)), nil + } + return "", err + } + if compatible { + continue + } + domain, key, _ := strings.Cut(claimKey, "\x00") + return fmt.Sprintf("binding requirement conflict in domain %q on %q among %s", + domain, key, formatSourcedConstraintsV1(claim.owners, claim.requirements)), nil + } + return "", nil +} + func stringSlicesEqualV1(left, right []string) bool { if len(left) != len(right) { return false @@ -916,6 +989,11 @@ func pathsOverlapV1(left string, right string) bool { } func (catalog *CatalogV1) addCandidateClaimsV1(claims *assignmentClaimsV1, + domains ProviderDomainSetV1, owner string, candidate ReleaseCandidateV1) (string, error) { + return catalog.addCandidateClaimsForAssignmentV1(claims, domains, owner, candidate) +} + +func (catalog *CatalogV1) addCandidateClaimsForAssignmentV1(claims *assignmentClaimsV1, domains ProviderDomainSetV1, owner string, candidate ReleaseCandidateV1) (string, error) { if candidate.Contract.Runtime != nil { if candidate.Contract.Runtime.InstallRoot != "" { @@ -996,12 +1074,8 @@ func (catalog *CatalogV1) addCandidateClaimsV1(claims *assignmentClaimsV1, return conflict, nil } for _, requirement := range selected.Requirements { - distribution, err := pythonprovider.PackageRootDistributionNameV1(requirement) - if err != nil { - return "", err - } - if conflict, err := addBindingRequirementClaimV1(claims, - domains.PackageManager, distribution, requirement, owner); err != nil { + if conflict, err := addBindingRequirementTextClaimV1( + claims, domains.PackageManager, requirement, owner); err != nil { return "", err } else if conflict != "" { return conflict, nil diff --git a/internal/toolcatalog/solver_test.go b/internal/toolcatalog/solver_test.go index 46df9ed9..35499ac0 100644 --- a/internal/toolcatalog/solver_test.go +++ b/internal/toolcatalog/solver_test.go @@ -1,6 +1,7 @@ package toolcatalog import ( + "fmt" "reflect" "strings" "testing" @@ -427,6 +428,29 @@ func TestBindingRequirementClaimsAllowCompatibleProviderConstraintsV1(t *testing solverTestActiveProvidersV1()); err != nil || conflict != "" { t.Fatalf("compatible binding requirements conflict = %q, %v", conflict, err) } + for _, requirement := range []string{"demo>=1rc1,<2", "demo>1rc1,<2", "demo>=1!2"} { + bindingLeft.Requirements = []string{requirement} + bindingRight.Requirements = []string{requirement} + application.Contributions = []RecordReferenceV1{solverTestAddRecordV1(t, catalog, &bindingLeft)} + source.Contributions = []RecordReferenceV1{solverTestAddRecordV1(t, catalog, &bindingRight)} + if conflict, err := catalog.assignmentConflictV1( + sets, []ReleaseCandidateV1{application, source}, domains, + solverTestActiveProvidersV1()); err != nil || conflict != "" { + t.Fatalf("duplicate complex binding requirement %q conflict = %q, %v", requirement, conflict, err) + } + } + for _, requirement := range []string{"demo>=3,<3", "demo==1,==2", "demo==1.*,!=1.*"} { + bindingLeft.Requirements = []string{requirement} + bindingRight.Requirements = []string{requirement} + application.Contributions = []RecordReferenceV1{solverTestAddRecordV1(t, catalog, &bindingLeft)} + source.Contributions = []RecordReferenceV1{solverTestAddRecordV1(t, catalog, &bindingRight)} + conflict, err := catalog.assignmentConflictV1( + sets, []ReleaseCandidateV1{application, source}, domains, + solverTestActiveProvidersV1()) + if err == nil && conflict == "" { + t.Fatalf("contradictory binding requirement %q was accepted", requirement) + } + } bindingLeft.Requirements = []string{"demo==1"} bindingRight.Name = "python-alt" @@ -440,6 +464,110 @@ func TestBindingRequirementClaimsAllowCompatibleProviderConstraintsV1(t *testing } } +func TestSolveCandidateSetsDeduplicatesSharedPythonRootsV1(t *testing.T) { + catalog, sets, domains := solverTestSharedPythonRootSetsV1(t, 32) + chosen, visited, err := catalog.solveCandidateSetsV1( + sets, domains, solverTestActiveProvidersV1(), len(sets)) + if err != nil { + t.Fatal(err) + } + if len(chosen) != len(sets) || visited != len(sets) { + t.Fatalf("shared-root solve = %d chosen, %d visited; want %d of each", + len(chosen), visited, len(sets)) + } +} + +func BenchmarkSolveCandidateSetsSharedPythonRootsV1(b *testing.B) { + for _, count := range []int{16, 32, 64, 1024} { + b.Run(fmt.Sprintf("roots-%d", count), func(b *testing.B) { + catalog, sets, domains := solverTestSharedPythonRootSetsV1(b, count) + b.ResetTimer() + for range b.N { + if _, _, err := catalog.solveCandidateSetsV1( + sets, domains, solverTestActiveProvidersV1(), len(sets)); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func BenchmarkSolveCandidateSetsDistinctPythonRootsV1(b *testing.B) { + for _, count := range []int{16, 32, 64} { + b.Run(fmt.Sprintf("roots-%d", count), func(b *testing.B) { + specifiersPerRoot := 1024 / count + requirements := make([]string, count) + for index := range requirements { + requirements[index] = "demo" + strings.Repeat(">=1,", specifiersPerRoot-1) + + fmt.Sprintf(">=1.%d", index) + } + catalog, sets, domains := solverTestPythonRootSetsV1(b, requirements) + b.ResetTimer() + for range b.N { + if _, _, err := catalog.solveCandidateSetsV1( + sets, domains, solverTestActiveProvidersV1(), len(sets)); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func solverTestSharedPythonRootSetsV1(tb testing.TB, count int) (*CatalogV1, + []orderedCandidateSetV1, []ProviderDomainSetV1) { + tb.Helper() + specifiers := make([]string, 1024) + for index := range specifiers { + specifiers[index] = ">=1" + } + requirements := make([]string, count) + for index := range requirements { + requirements[index] = "demo" + strings.Join(specifiers, ",") + } + return solverTestPythonRootSetsV1(tb, requirements) +} + +func solverTestPythonRootSetsV1(tb testing.TB, requirements []string) (*CatalogV1, + []orderedCandidateSetV1, []ProviderDomainSetV1) { + tb.Helper() + catalog := &CatalogV1{records: make(map[recordKeyV1]loadedRecordV1)} + domain := ProviderDomainSetV1{ + Scope: "source-builder:shared", PackageManager: "shared/packages", + Filesystem: "shared/filesystem", Environment: "shared/environment", + Exports: "shared/exports", Capabilities: "shared/capabilities", + } + sets := make([]orderedCandidateSetV1, len(requirements)) + for index := range sets { + binding := &BindingContractV1{ + Schema: BindingContractSchemaV1, + ID: fmt.Sprintf("tool:solver-benchmark/releases/1/bindings/python-%d/contract", index), + Name: fmt.Sprintf("python-%d", index), Package: "demo", + Requirements: []string{requirements[index]}, + SupportedPython: []string{}, SupportedTags: []string{}, BundledComponents: []BundledComponentV1{}, + CLI: ToolExportV1{Name: "demo", Path: "/opt/demo/bin/demo"}, + } + digest, err := canonical.Sum("portable-tool-record", portableToolRecordIdentityV1, binding) + if err != nil { + tb.Fatal(err) + } + reference := RecordReferenceV1{ID: binding.ID, Digest: digest} + catalog.records[recordKeyV1{ID: binding.ID, Digest: digest}] = loadedRecordV1{ + ID: binding.ID, Schema: binding.Schema, Digest: digest, Value: binding, + } + sets[index] = orderedCandidateSetV1{ + group: CanonicalRequirementGroupV1{ + Scope: fmt.Sprintf("source-builder:root-%d", index), Tool: "demo", Context: "build", + }, + candidates: []ReleaseCandidateV1{{ + Manifest: ReleaseManifestV1{Version: "1", Revision: "1"}, + Contributions: []RecordReferenceV1{reference}, + }}, + domains: domain, + } + } + return catalog, sets, []ProviderDomainSetV1{domain} +} + func TestBindingClaimsRequireSharedPythonInterpreterV1(t *testing.T) { catalog := candidateTestCatalogV1(t) applicationSet, sourceSet := solverTestCandidateSetsV1(t, catalog) @@ -619,6 +747,121 @@ func TestActiveProviderConstraintsBacktrackAndAttributeConflictsV1(t *testing.T) } } +func TestActivePythonRootUsesCandidateExactWitnessV1(t *testing.T) { + catalog := candidateTestCatalogV1(t) + applicationSet, _ := solverTestCandidateSetsV1(t, catalog) + candidate := applicationSet.Candidates[0] + binding := cloneBindingContractV1(validRecordValuesV1()[4].(*BindingContractV1)) + binding.Requirements = []string{"demo==1!2.3"} + candidate.Contributions = []RecordReferenceV1{solverTestAddRecordV1(t, catalog, &binding)} + applicationSet.Candidates = []ReleaseCandidateV1{candidate} + + activeSource := solverTestActiveSourceV1("source-builder:demo", "lock-state", "application/python") + activeSource.PythonBindings = []ActivePythonBindingConstraintV1{{ + Name: "python", Requirements: []string{"demo==1!2.*"}, SupportedPython: []string{}, + }} + operation := solverTestOperationV1() + operation.ActiveProviders = solverTestActiveProvidersV1(activeSource) + result, err := catalog.ResolveSelectedClosuresV1( + []ReleaseCandidateSetV1{applicationSet}, solverTestBuildDomainsV1(false), operation) + if err != nil { + t.Fatalf("resolve active epoch prefix with candidate witness: %v", err) + } + if len(result.Closures) != 1 || result.VisitedStates != "1" { + t.Errorf("epoch-prefix witness solve = %+v, visited %s; want one closure in one state", + result.Closures, result.VisitedStates) + } +} + +func TestUnprovenPythonRootBacktracksToCompatibleCandidateV1(t *testing.T) { + catalog := candidateTestCatalogV1(t) + _, sourceSet := solverTestCandidateSetsV1(t, catalog) + newest := sourceSet.Candidates[0] + older := sourceSet.Candidates[1] + unproven := cloneBindingContractV1(validRecordValuesV1()[4].(*BindingContractV1)) + unproven.Requirements = []string{"demo==1!2.*"} + exact := cloneBindingContractV1(&unproven) + exact.Requirements = []string{"demo==1!2.3"} + newest.Contributions = []RecordReferenceV1{solverTestAddRecordV1(t, catalog, &unproven)} + older.Contributions = []RecordReferenceV1{solverTestAddRecordV1(t, catalog, &exact)} + sourceSet.Candidates = []ReleaseCandidateV1{newest, older} + + result, err := catalog.ResolveSelectedClosuresV1( + []ReleaseCandidateSetV1{sourceSet}, solverTestBuildDomainsV1(false), solverTestOperationV1()) + if err != nil { + t.Fatalf("backtrack from unproven Python root: %v", err) + } + if len(result.Closures) != 1 || result.Closures[0].Provenance.Version != "1.2.3" || + result.VisitedStates != "2" { + t.Errorf("unproven-root fallback = closures %+v visited %s; want 1.2.3 after two states", + result.Closures, result.VisitedStates) + } +} + +func TestArbitraryPythonRootBacktracksToCompatibleCandidateV1(t *testing.T) { + catalog := candidateTestCatalogV1(t) + _, sourceSet := solverTestCandidateSetsV1(t, catalog) + newest := sourceSet.Candidates[0] + older := sourceSet.Candidates[1] + arbitrary := cloneBindingContractV1(validRecordValuesV1()[4].(*BindingContractV1)) + arbitrary.Requirements = []string{"demo===v1.0"} + exact := cloneBindingContractV1(&arbitrary) + exact.Requirements = []string{"demo==0.9"} + newest.Contributions = []RecordReferenceV1{solverTestAddRecordV1(t, catalog, &arbitrary)} + older.Contributions = []RecordReferenceV1{solverTestAddRecordV1(t, catalog, &exact)} + sourceSet.Candidates = []ReleaseCandidateV1{newest, older} + + activeSource := solverTestActiveSourceV1("source-builder:other", "lock-state", "source-builder/python") + activeSource.PythonBindings = []ActivePythonBindingConstraintV1{{ + Name: "python", Requirements: []string{"demo<1"}, SupportedPython: []string{}, + }} + operation := solverTestOperationV1() + operation.ActiveProviders = solverTestActiveProvidersV1(activeSource) + result, err := catalog.ResolveSelectedClosuresV1( + []ReleaseCandidateSetV1{sourceSet}, solverTestBuildDomainsV1(false), operation) + if err != nil { + t.Fatalf("backtrack from incompatible arbitrary Python root: %v", err) + } + if len(result.Closures) != 1 || result.Closures[0].Provenance.Version != "1.2.3" || + result.VisitedStates != "2" { + t.Errorf("arbitrary-root fallback = closures %+v visited %s; want 1.2.3 after two states", + result.Closures, result.VisitedStates) + } +} + +func TestPythonRootProofBudgetBacktracksToExactCandidateV1(t *testing.T) { + catalog := candidateTestCatalogV1(t) + _, sourceSet := solverTestCandidateSetsV1(t, catalog) + newest := sourceSet.Candidates[0] + older := sourceSet.Candidates[1] + overBudget := cloneBindingContractV1(validRecordValuesV1()[4].(*BindingContractV1)) + overBudget.Requirements = []string{"demo>=1,>=2"} + exact := cloneBindingContractV1(&overBudget) + exact.Requirements = []string{"demo==2"} + newest.Contributions = []RecordReferenceV1{solverTestAddRecordV1(t, catalog, &overBudget)} + older.Contributions = []RecordReferenceV1{solverTestAddRecordV1(t, catalog, &exact)} + sourceSet.Candidates = []ReleaseCandidateV1{newest, older} + + activeSource := solverTestActiveSourceV1("source-builder:other", "lock-state", "source-builder/python") + activeSource.PythonBindings = []ActivePythonBindingConstraintV1{{ + Name: "python", Requirements: []string{ + "demo" + strings.Repeat(">=1,", 1022) + ">=1", + }, SupportedPython: []string{}, + }} + operation := solverTestOperationV1() + operation.ActiveProviders = solverTestActiveProvidersV1(activeSource) + result, err := catalog.ResolveSelectedClosuresV1( + []ReleaseCandidateSetV1{sourceSet}, solverTestBuildDomainsV1(false), operation) + if err != nil { + t.Fatalf("backtrack from aggregate Python proof budget: %v", err) + } + if len(result.Closures) != 1 || result.Closures[0].Provenance.Version != "1.2.3" || + result.VisitedStates != "2" { + t.Errorf("proof-budget fallback = closures %+v visited %s; want 1.2.3 after two states", + result.Closures, result.VisitedStates) + } +} + func TestActiveProviderConstraintsAcceptEveryCanonicalFamilyV1(t *testing.T) { catalog := candidateTestCatalogV1(t) applicationSet, _ := solverTestCandidateSetsV1(t, catalog) @@ -891,6 +1134,47 @@ func TestJointAssignmentCapFailsClosedV1(t *testing.T) { } } +func TestPythonRootPartialConflictsPruneBeforeStateCapV1(t *testing.T) { + requirements := make([]string, 10) + for index := range requirements { + requirements[index] = "demo==2" + } + catalog, sets, domains := solverTestPythonRootSetsV1(t, requirements) + + conflictingBinding := &BindingContractV1{ + Schema: BindingContractSchemaV1, + ID: "tool:solver-cap/releases/1/bindings/python-conflict/contract", + Name: "python-conflict", Package: "demo", + Requirements: []string{"demo==1"}, + SupportedPython: []string{}, SupportedTags: []string{}, BundledComponents: []BundledComponentV1{}, + CLI: ToolExportV1{Name: "demo", Path: "/opt/demo/bin/demo"}, + } + conflictingReference := solverTestAddRecordV1(t, catalog, conflictingBinding) + conflicting := sets[0].candidates[0] + conflicting.Manifest.Version = "2" + conflicting.Contributions = []RecordReferenceV1{conflictingReference} + compatible := sets[0].candidates[0] + compatible.Manifest.Version = "1" + sets[0].candidates = []ReleaseCandidateV1{conflicting, compatible} + for index := 1; index < len(sets); index++ { + newer := sets[index].candidates[0] + newer.Manifest.Version = "2" + older := sets[index].candidates[0] + older.Manifest.Version = "1" + sets[index].candidates = []ReleaseCandidateV1{newer, older} + } + + chosen, visited, err := catalog.solveCandidateSetsV1( + sets, domains, solverTestActiveProvidersV1(), 1024) + if err != nil { + t.Fatalf("partial Python-root pruning before state cap: %v", err) + } + if len(chosen) != len(sets) || chosen[0].Manifest.Version != "1" || visited != 13 { + t.Fatalf("partial root pruning = %d chosen, first version %q, %d visited; want 10, 1, 13", + len(chosen), chosen[0].Manifest.Version, visited) + } +} + func TestSelectedClosureIdentityExcludesValidationAndSourceOnlyDataV1(t *testing.T) { catalog := candidateTestCatalogV1(t) group := candidateTestGroupV1()