diff --git a/pkg/apk/apk/repo.go b/pkg/apk/apk/repo.go index 91cfb8d0c..018f26949 100644 --- a/pkg/apk/apk/repo.go +++ b/pkg/apk/apk/repo.go @@ -33,8 +33,7 @@ import ( ) var ( - parsedVersions sync.Map // map[string]Version - parsedConstraints sync.Map // map[string]ParsedConstraint + parsedVersions sync.Map // map[string]Version ) // NamedIndex an index that contains all of its packages, @@ -272,7 +271,7 @@ func newPkgResolver(ctx context.Context, indexes []NamedIndex) *PkgResolver { for _, pkgVersions := range allPkgs { for _, pkg := range pkgVersions { for _, provide := range pkg.Provides { - name := cachedResolvePackageNameVersionPin(provide).Name + name := constraintName(provide) pkgNameMap[name] = append(pkgNameMap[name], pkg) } } @@ -314,7 +313,7 @@ func (p *PkgResolver) nextPackage(packages []string, dq map[*RepositoryPackage]s // Disqualify anything that provides "constraint". This is used for !foo style constraints. func (p *PkgResolver) disqualifyProviders(constraint string, dq map[*RepositoryPackage]string) { - parsed := cachedResolvePackageNameVersionPin(constraint) + parsed := ResolvePackageNameVersionPin(constraint) providers, ok := p.nameMap[parsed.Name] if !ok { return @@ -346,7 +345,7 @@ func (p *PkgResolver) conflictingVersion(constraint ParsedConstraint, conflict * } for _, confProv := range conflict.Provides { - confConstraint := cachedResolvePackageNameVersionPin(confProv) + confConstraint := ResolvePackageNameVersionPin(confProv) if confConstraint.Name != constraint.Name { // Not the constraint we're looking for. continue @@ -366,7 +365,7 @@ func (p *PkgResolver) conflictingVersion(constraint ParsedConstraint, conflict * // Disqualify anything that conflicts with the given pkg. func (p *PkgResolver) disqualifyConflicts(pkg *RepositoryPackage, dq map[*RepositoryPackage]string) { for _, prov := range pkg.Provides { - constraint := cachedResolvePackageNameVersionPin(prov) + constraint := ResolvePackageNameVersionPin(prov) providers, ok := p.nameMap[constraint.Name] if !ok { continue @@ -405,7 +404,7 @@ func (p *PkgResolver) pick(pkg *RepositoryPackage) error { p.selected[pkg.Name] = pkg for _, prov := range pkg.Provides { - constraint := cachedResolvePackageNameVersionPin(prov) + constraint := ResolvePackageNameVersionPin(prov) if conflict, ok := p.selected[constraint.Name]; ok { return fmt.Errorf("selecting package %s conflicts with %s on %q", pkg.Filename(), conflict.Filename(), constraint.Name) } @@ -436,7 +435,7 @@ func (p *PkgResolver) constrain(constraints []string, dq map[*RepositoryPackage] continue } - parsed := cachedResolvePackageNameVersionPin(constraint) + parsed := ResolvePackageNameVersionPin(constraint) if parsed.dep == versionAny { continue } @@ -466,7 +465,7 @@ func (p *PkgResolver) constrain(constraints []string, dq map[*RepositoryPackage] } } else { for _, provides := range provider.Provides { - pp := cachedResolvePackageNameVersionPin(provides) + pp := ResolvePackageNameVersionPin(provides) if pp.Name != parsed.Name { continue } @@ -587,7 +586,7 @@ func (p *PkgResolver) GetPackageWithDependencies(ctx context.Context, pkgName st return nil, nil, nil, &ConstraintError{pkgName, err} } - pin := cachedResolvePackageNameVersionPin(pkgName).pin + pin := ResolvePackageNameVersionPin(pkgName).pin deps, conflicts, err := p.getPackageDependencies(ctx, pkg, pin, parents, localExisting, existingOrigins, dq) if err != nil { return nil, nil, nil, &DepError{pkg, err} @@ -616,7 +615,7 @@ func (p *PkgResolver) GetPackageWithDependencies(ctx context.Context, pkgName st var matchCount int for _, subDep := range installIfPkg.InstallIf { // two possibilities: package name, or name=version - constraint := cachedResolvePackageNameVersionPin(subDep) + constraint := ResolvePackageNameVersionPin(subDep) name, version := constraint.Name, constraint.Version // precise match of whatever it is, take it and continue if _, ok := added[subDep]; ok { @@ -646,7 +645,7 @@ func (p *PkgResolver) GetPackageWithDependencies(ctx context.Context, pkgName st // and decreasing from there. In general, the first one in the list is the best match. This function // returns multiple in case you need to see all potential matches. func (p *PkgResolver) ResolvePackage(pkgName string, dq map[*RepositoryPackage]string) ([]*RepositoryPackage, error) { - constraint := cachedResolvePackageNameVersionPin(pkgName) + constraint := ResolvePackageNameVersionPin(pkgName) name, version, compare, pin := constraint.Name, constraint.Version, constraint.dep, constraint.pin pkgsWithVersions, ok := p.nameMap[name] if !ok { @@ -672,7 +671,7 @@ func (p *PkgResolver) ResolvePackage(pkgName string, dq map[*RepositoryPackage]s // This is like ResolvePackage but we only care about the best match and not all matches. func (p *PkgResolver) resolvePackage(pkgName string, dq map[*RepositoryPackage]string) (*RepositoryPackage, error) { - constraint := cachedResolvePackageNameVersionPin(pkgName) + constraint := ResolvePackageNameVersionPin(pkgName) name, version, compare, pin := constraint.Name, constraint.Version, constraint.dep, constraint.pin pkgsWithVersions, ok := p.nameMap[name] @@ -728,7 +727,7 @@ func (p *PkgResolver) getPackageDependencies(ctx context.Context, pkg *Repositor myProvides := make(map[string]bool, 2*len(pkg.Provides)) // see if we provide this for _, provide := range pkg.Provides { - name := cachedResolvePackageNameVersionPin(provide).Name + name := constraintName(provide) myProvides[provide] = true myProvides[name] = true } @@ -755,7 +754,7 @@ func (p *PkgResolver) getPackageDependencies(ctx context.Context, pkg *Repositor } // this package might be pinned to a version - constraint := cachedResolvePackageNameVersionPin(dep) + constraint := ResolvePackageNameVersionPin(dep) name, version, compare := constraint.Name, constraint.Version, constraint.dep // see if we provide this if myProvides[name] || myProvides[dep] { @@ -800,7 +799,7 @@ func (p *PkgResolver) getPackageDependencies(ctx context.Context, pkg *Repositor // selected satisfy this constraint. satisfiedByProvide := false for _, provide := range picked.Provides { - prostraint := cachedResolvePackageNameVersionPin(provide) + prostraint := ResolvePackageNameVersionPin(provide) pname, pversion, pcompare := prostraint.Name, prostraint.Version, prostraint.dep if pname != name { continue @@ -869,7 +868,7 @@ func (p *PkgResolver) getPackageDependencies(ctx context.Context, pkg *Repositor } pkgs := options[lowest] - name := cachedResolvePackageNameVersionPin(lowest).Name + name := ResolvePackageNameVersionPin(lowest).Name // Remove this from our constraints. constraints = slices.DeleteFunc(constraints, func(s string) bool { @@ -930,18 +929,6 @@ func cachedParseVersion(version string) (Version, error) { return parsed, nil } -func cachedResolvePackageNameVersionPin(pkgName string) ParsedConstraint { - cached, ok := parsedConstraints.Load(pkgName) - if ok { - return cached.(ParsedConstraint) - } - - pin := ResolvePackageNameVersionPin(pkgName) - - parsedConstraints.Store(pkgName, pin) - return pin -} - // sortPackages sorts a slice of packages in descending order of preference, based on // matching origin to a provided comparison package, whether or not one of the packages // already is installed, the versions, and whether an origin already exists. @@ -1118,7 +1105,7 @@ func (p *PkgResolver) getDepVersionForName(pkg *repositoryPackage, name string) return pkg.Version } for _, prov := range pkg.Provides { - constraint := cachedResolvePackageNameVersionPin(prov) + constraint := ResolvePackageNameVersionPin(prov) if constraint.Name == name { return constraint.Version } diff --git a/pkg/apk/apk/resolve_constraint_test.go b/pkg/apk/apk/resolve_constraint_test.go new file mode 100644 index 000000000..544e21301 --- /dev/null +++ b/pkg/apk/apk/resolve_constraint_test.go @@ -0,0 +1,144 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package apk + +import ( + "regexp" + "strings" + "testing" +) + +// regexResolvePackageNameVersionPin is the previous, regex-based +// implementation of ResolvePackageNameVersionPin, kept as the oracle the +// hand-written parser is checked against. +func regexResolvePackageNameVersionPin(pkgName string) ParsedConstraint { + endsWithReleaseStr := regexp.MustCompile(`-r\d+$`) + packageNameRegex := regexp.MustCompile(`^([^@=><~]+)(([=><~]+)([^@]+))?(@([a-zA-Z0-9]+))?$`) + packageNameRegex.Longest() + + if strings.HasPrefix(pkgName, "so:") { + onlyPkgName, pkgVersion, found := strings.Cut(pkgName, "=") + if found && !endsWithReleaseStr.MatchString(pkgVersion) { + pkgName = onlyPkgName + "=0." + pkgVersion + } + } + + parts := packageNameRegex.FindAllStringSubmatch(pkgName, -1) + if len(parts) == 0 || len(parts[0]) < 2 { + return ParsedConstraint{Name: pkgName, dep: versionAny} + } + p := ParsedConstraint{ + Name: parts[0][1], + Version: parts[0][4], + pin: parts[0][6], + dep: versionAny, + } + switch parts[0][3] { + case "=": + p.dep = versionEqual + case ">": + p.dep = versionGreater + case "<": + p.dep = versionLess + case ">=": + p.dep = versionGreaterEqual + case "<=": + p.dep = versionLessEqual + case "~", "=~": + p.dep = versionTilde + } + return p +} + +var resolveCases = []string{ + "", + "foo", + "foo=1.2.3-r0", + "foo>=1.2", + "foo<=1.2", + "foo>1", + "foo<1", + "foo~1.2", + "foo=~1.2", + "foo=1.2.3-r0@wolfi", + "foo@wolfi", + "foo@", + "foo=", + "foo==", + "foo===", + "foo=@wolfi", + "foo=1@", + "foo=1@pin-with-dash", + "foo=1@p@q", + "foo@p@q", + "=1.2", + "@pin", + "so:libfoo.so.1=1", + "so:libfoo.so.1=1.2.3-r0", + "so:libfoo.so.1=1-r", + "so:libfoo.so.1=1-r5x", + "so:libfoo.so.1=1@wolfi", + "so:libfoo.so.1", + "so:=1", + "cmd:tool=1.0-r0", + "pc:libfoo=1.0", + "cmd:weird@name=1", + "a=>1", + "a=<1", + "a==1", + "a~=1", + "a=1=2", + "a=1>2@pin", + "héllo=1", + "a=1@pïn", + "a>=", + "a>=@x", +} + +// lockPackageNameRegex is the copy pkg/build/lock.go used to carry, without +// the leftmost-longest setting and without the shared library tweak. +var lockPackageNameRegex = regexp.MustCompile(`^([^@=><~]+)(([=><~]+)([^@]+))?(@([a-zA-Z0-9]+))?$`) + +func checkAgainstRegex(t *testing.T, in string) { + t.Helper() + want := regexResolvePackageNameVersionPin(in) + if got := ResolvePackageNameVersionPin(in); got != want { + t.Errorf("ResolvePackageNameVersionPin(%q) = %+v, regex says %+v", in, got, want) + } + if got := constraintName(in); got != want.Name { + t.Errorf("constraintName(%q) = %q, regex says %q", in, got, want.Name) + } + + parts := lockPackageNameRegex.FindStringSubmatch(in) + got, ok := ParseConstraint(in) + if ok != (parts != nil) { + t.Errorf("ParseConstraint(%q) ok = %v, regex matched = %v", in, ok, parts != nil) + } else if ok && got.Name != parts[1] { + t.Errorf("ParseConstraint(%q).Name = %q, regex says %q", in, got.Name, parts[1]) + } +} + +func TestResolvePackageNameVersionPinMatchesRegex(t *testing.T) { + for _, in := range resolveCases { + checkAgainstRegex(t, in) + } +} + +func FuzzResolvePackageNameVersionPinMatchesRegex(f *testing.F) { + for _, in := range resolveCases { + f.Add(in) + } + f.Fuzz(checkAgainstRegex) +} diff --git a/pkg/apk/apk/version.go b/pkg/apk/apk/version.go index 7dbb8640f..2b8032607 100644 --- a/pkg/apk/apk/version.go +++ b/pkg/apk/apk/version.go @@ -36,13 +36,11 @@ import ( // 2. allows pulling in dependencies for the tagged package from the tagged repository (though it prefers to use untagged repositories to satisfy dependencies if possible) var ( - versionRegex = regexp.MustCompile(`^([0-9]+)((\.[0-9]+)*)([a-z]?)((_alpha|_beta|_pre|_rc)([0-9]*))?((_cvs|_svn|_git|_hg|_p)([0-9]*))?((-r)([0-9]+))?$`) - packageNameRegex = regexp.MustCompile(`^([^@=><~]+)(([=><~]+)([^@]+))?(@([a-zA-Z0-9]+))?$`) + versionRegex = regexp.MustCompile(`^([0-9]+)((\.[0-9]+)*)([a-z]?)((_alpha|_beta|_pre|_rc)([0-9]*))?((_cvs|_svn|_git|_hg|_p)([0-9]*))?((-r)([0-9]+))?$`) ) func init() { versionRegex.Longest() - packageNameRegex.Longest() } type packageVersionPreModifier int @@ -367,8 +365,13 @@ func (p ParsedConstraint) SatisfiedBy(v Version) (bool, error) { return p.dep.satisfies(v, pv), nil } -var endsWithReleaseStr = regexp.MustCompile(`-r\d+$`) - +// ResolvePackageNameVersionPin splits a dependency or provides string such as +// "name>=1.2.3-r0@pin" into its name, comparison operator, version and pin. +// +// The accepted shape is a name made of any characters except "@=><~", then +// optionally an operator run followed by a version that may not contain "@", +// then optionally "@" and an alphanumeric pin. Anything that does not fit is +// returned whole as the name with no version constraint. func ResolvePackageNameVersionPin(pkgName string) ParsedConstraint { // Due to https://github.com/chainguard-dev/melange/pull/1871, // we have to treat shared library depends/provides @@ -384,49 +387,113 @@ func ResolvePackageNameVersionPin(pkgName string) ParsedConstraint { // versioned depends/provides containing the package version. if strings.HasPrefix(pkgName, "so:") { onlyPkgName, pkgVersion, found := strings.Cut(pkgName, "=") - if found && !endsWithReleaseStr.MatchString(pkgVersion) { + if found && !hasReleaseSuffix(pkgVersion) { pkgName = onlyPkgName + "=0." + pkgVersion } } - parts := packageNameRegex.FindAllStringSubmatch(pkgName, -1) - if len(parts) == 0 || len(parts[0]) < 2 { - return ParsedConstraint{ - Name: pkgName, - dep: versionAny, - } + p, ok := ParseConstraint(pkgName) + if !ok { + return ParsedConstraint{Name: pkgName, dep: versionAny} + } + return p +} + +// constraintName returns ResolvePackageNameVersionPin(pkgName).Name without +// allocating for the common shapes. The shared library tweak above only +// rewrites the version, so the name is unaffected unless parsing fails, in +// which case the full function decides. +func constraintName(pkgName string) string { + if p, ok := ParseConstraint(pkgName); ok { + return p.Name + } + return ResolvePackageNameVersionPin(pkgName).Name +} + +// ParseConstraint parses pkgName like ResolvePackageNameVersionPin but without +// the shared library tweak, and reports false instead of falling back to the +// whole string when pkgName does not fit the accepted shape. +func ParseConstraint(pkgName string) (ParsedConstraint, bool) { + // A version never contains "@", so everything after the first one is the pin. + head, pin, hasPin := strings.Cut(pkgName, "@") + if hasPin && !isAlphanumeric(pin) { + return ParsedConstraint{}, false + } + + // The name is everything before the first operator character. + nameEnd := strings.IndexAny(head, "=><~") + if nameEnd < 0 { + nameEnd = len(head) + } + if nameEnd == 0 { + return ParsedConstraint{}, false + } + p := ParsedConstraint{Name: head[:nameEnd], dep: versionAny, pin: pin} + + // What remains is an operator run followed by a non-empty version. If the + // run reaches the end, its last character is the version. + constraint := head[nameEnd:] + if constraint == "" { + return p, true + } + opEnd := 1 + for opEnd < len(constraint)-1 && isOperator(constraint[opEnd]) { + opEnd++ + } + if opEnd == len(constraint) { + return ParsedConstraint{}, false } - // layout: [full match, name, =version, =|>|<, version, @pin, pin] - p := ParsedConstraint{ - Name: parts[0][1], - Version: parts[0][4], - pin: parts[0][6], - dep: versionAny, - } - - matcher := parts[0][3] - if matcher != "" { - // we have an equal - switch matcher { - case "=": - p.dep = versionEqual - case ">": - p.dep = versionGreater - case "<": - p.dep = versionLess - case ">=": - p.dep = versionGreaterEqual - case "<=": - p.dep = versionLessEqual - case "~": - p.dep = versionTilde - case "=~": - p.dep = versionTilde - default: - p.dep = versionAny + p.dep = parseOperator(constraint[:opEnd]) + p.Version = constraint[opEnd:] + return p, true +} + +func parseOperator(op string) versionDependency { + switch op { + case "=": + return versionEqual + case ">": + return versionGreater + case "<": + return versionLess + case ">=": + return versionGreaterEqual + case "<=": + return versionLessEqual + case "~", "=~": + return versionTilde + default: + return versionAny + } +} + +func isOperator(c byte) bool { + return c == '=' || c == '>' || c == '<' || c == '~' +} + +// isAlphanumeric reports whether s is non-empty and made of ASCII letters and digits. +func isAlphanumeric(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + isLetter := ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') + isDigit := '0' <= c && c <= '9' + if !isLetter && !isDigit { + return false } } - return p + return true +} + +// hasReleaseSuffix reports whether s ends in "-r" followed by one or more digits. +func hasReleaseSuffix(s string) bool { + end := len(s) + for end > 0 && '0' <= s[end-1] && s[end-1] <= '9' { + end-- + } + return end < len(s) && strings.HasSuffix(s[:end], "-r") } type filterOptions struct { @@ -513,7 +580,7 @@ func filterPackages(pkgs []*repositoryPackage, dq map[*RepositoryPackage]string, } for _, prov := range pkg.Provides { - version := cachedResolvePackageNameVersionPin(prov).Version + version := ResolvePackageNameVersionPin(prov).Version if version == "" { continue } diff --git a/pkg/build/lock.go b/pkg/build/lock.go index 2705f05c5..efd893d89 100644 --- a/pkg/build/lock.go +++ b/pkg/build/lock.go @@ -19,7 +19,6 @@ import ( "fmt" "maps" "reflect" - "regexp" "sort" "strings" @@ -132,15 +131,15 @@ func resolvePackageList(ctx context.Context, mc *MultiArch) ([]resolved, map[typ r.versions[pkg.Name] = pkg.Version for _, prov := range pkg.Provides { - parts := packageNameRegex.FindAllStringSubmatch(prov, -1) - if len(parts) == 0 || len(parts[0]) < 2 { + constraint, ok := apk.ParseConstraint(prov) + if !ok { continue } ps, ok := r.provided[pkg.Name] if !ok { ps = sets.New[string]() } - ps.Insert(parts[0][1]) + ps.Insert(constraint.Name) r.provided[pkg.Name] = ps } } @@ -346,6 +345,3 @@ func unify(originals []string, inputs []resolved) (map[string][]string, map[stri return byArch, nil, nil } - -// Copied from go-apk's version.go -var packageNameRegex = regexp.MustCompile(`^([^@=><~]+)(([=><~]+)([^@]+))?(@([a-zA-Z0-9]+))?$`)