From 0bbd5f3d276500e6b00800846f57b58c0142c102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20Th=C3=B6mmes?= Date: Mon, 7 Sep 2026 15:06:56 +0200 Subject: [PATCH] apk: replace the constraint regex and its cache with a hand-written parser ResolvePackageNameVersionPin parsed with a regex and hid the cost behind a process-global sync.Map keyed by every constraint string ever seen. The grammar is small enough to parse by hand in about 25 ns with no allocations, faster than the cache lookup was, so the cache is deleted and all call sites parse directly. On the Wolfi x86_64 index a resolver build goes from 63 ms to 36 ms. The resolver build uses a name-only path that skips the shared library version tweak, which never changes the name, so it allocates nothing per provide. ParseConstraint is exported as the variant without the tweak that reports whether the string fit the grammar, and pkg/build/lock.go uses it in place of its own copy of the regex. The previous regex implementations stay in the tests as oracles, with a table, a fuzz test and a check over every string in the Wolfi indexes. --- pkg/apk/apk/repo.go | 47 +++----- pkg/apk/apk/resolve_constraint_test.go | 144 +++++++++++++++++++++++ pkg/apk/apk/version.go | 151 ++++++++++++++++++------- pkg/build/lock.go | 10 +- 4 files changed, 273 insertions(+), 79 deletions(-) create mode 100644 pkg/apk/apk/resolve_constraint_test.go 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]+))?$`)