fix: restore case-insensitive java globbing for licenses, version and nested jars

The `archiveEntries` refactor dropped `GlobMatch`'s case-insensitive flag, so with nested cataloging
off (the default) three lookups that main matched regardless of case became case-sensitive:

- license files under `/META-INF/*` and `/*` (`meta-inf/LICENSE` was missed)
- `/version.properties`
- the `**/*.jar`, `**/*.war` check behind the "nested archives not cataloged" unknown, so `LIB.JAR`
  slipped past it

`archiveEntries` now has a `globFold` for those call sites. The zip side restores
`GlobMatch(true, ...)`, and the resolver side matches case-folded over every entry, since resolver
globs are case-sensitive.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman
2026-10-21 11:37:45 -04:00
parent abc9325081
commit 78f1d44e3c
5 changed files with 103 additions and 9 deletions
+10 -3
View File
@@ -46,7 +46,7 @@ func (z ZipFileManifest) Add(entry string, info os.FileInfo) {
}
// GlobMatch returns the path keys to files (not directories) that match the given value(s).
func (z ZipFileManifest) GlobMatch(patterns ...string) []string {
func (z ZipFileManifest) GlobMatch(caseInsensitive bool, patterns ...string) []string {
uniqueMatches := strset.New()
for _, pattern := range patterns {
@@ -58,7 +58,11 @@ func (z ZipFileManifest) GlobMatch(patterns ...string) []string {
// We want to match globs as if entries begin with a leading slash (akin to an absolute path)
// so that glob logic is consistent inside and outside of ZIP archives
normalizedEntry := normalizeZipEntryName(entry)
normalizedEntry := normalizeZipEntryName(caseInsensitive, entry)
if caseInsensitive {
pattern = strings.ToLower(pattern)
}
matches, err := doublestar.Match(pattern, normalizedEntry)
if err != nil {
@@ -78,7 +82,10 @@ func (z ZipFileManifest) GlobMatch(patterns ...string) []string {
}
// normalizeZipEntryName takes the given path entry and ensures it is prefixed with "/".
func normalizeZipEntryName(entry string) string {
func normalizeZipEntryName(caseInsensitive bool, entry string) string {
if caseInsensitive {
entry = strings.ToLower(entry)
}
if !strings.HasPrefix(entry, "/") {
return "/" + entry
}
+5 -1
View File
@@ -122,6 +122,10 @@ func TestZipFileManifest_GlobMatch(t *testing.T) {
"**/a-file.txt",
[]string{"some-dir/a-file.txt"},
},
{
"**/A-file.txt",
[]string{"some-dir/a-file.txt"},
},
{
"**/*.zip",
[]string{"nested.zip"},
@@ -132,7 +136,7 @@ func TestZipFileManifest_GlobMatch(t *testing.T) {
t.Run(tc.glob, func(t *testing.T) {
glob := tc.glob
results := z.GlobMatch(glob)
results := z.GlobMatch(true, glob)
require.ElementsMatch(t, tc.expected, results)
})
+35 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"strings"
"github.com/bmatcuk/doublestar/v4"
"github.com/scylladb/go-set/strset"
"github.com/anchore/syft/internal"
@@ -21,6 +22,10 @@ type archiveEntries interface {
// ("/META-INF/*"); the names returned have none.
glob(patterns ...string) []string
// globFold is glob ignoring case, for names java tooling does not agree on the case of
// ("META-INF/LICENSE" vs "meta-inf/license", "X.JAR").
globFold(patterns ...string) []string
// contents reads the named entries, keyed by the same names.
contents(ctx context.Context, names ...string) (map[string]string, error)
}
@@ -40,7 +45,11 @@ func newZipEntries(ctx context.Context, archivePath string) (*zipEntries, error)
}
func (z *zipEntries) glob(patterns ...string) []string {
return z.fileManifest.GlobMatch(patterns...)
return z.fileManifest.GlobMatch(false, patterns...)
}
func (z *zipEntries) globFold(patterns ...string) []string {
return z.fileManifest.GlobMatch(true, patterns...)
}
func (z *zipEntries) contents(ctx context.Context, names ...string) (map[string]string, error) {
@@ -73,6 +82,31 @@ func (r *resolverEntries) glob(patterns ...string) []string {
return names
}
// globFold walks every entry, since a resolver's globs are case-sensitive. The callers ask for a handful
// of names once per archive, so the walk is cheap next to cataloging the archive.
func (r *resolverEntries) globFold(patterns ...string) []string {
var folded []string
for _, pattern := range patterns {
folded = append(folded, strings.ToLower(pattern))
}
seen := strset.New()
var names []string
for location := range r.resolver.AllLocations(context.Background()) {
name := strings.TrimPrefix(location.LocalPath(), "/")
if seen.Has(name) {
continue
}
for _, pattern := range folded {
if ok, _ := doublestar.Match(pattern, "/"+strings.ToLower(name)); ok {
seen.Add(name)
names = append(names, name)
break
}
}
}
return names
}
func (r *resolverEntries) contents(_ context.Context, names ...string) (map[string]string, error) {
out := make(map[string]string, len(names))
for _, name := range names {
@@ -0,0 +1,49 @@
package java
import (
"archive/zip"
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/file"
)
// java tooling doesn't agree on case ("meta-inf/LICENSE", "LIB.JAR"), and the license, version and
// nested-archive lookups have always matched regardless of it
func Test_archiveEntries_globFoldIgnoresCase(t *testing.T) {
names := []string{"meta-inf/LICENSE.txt", "Version.Properties", "lib/DEP.JAR", "META-INF/MANIFEST.MF"}
archivePath := filepath.Join(t.TempDir(), "app.jar")
f, err := os.Create(archivePath)
require.NoError(t, err)
zw := zip.NewWriter(f)
for _, name := range names {
_, err := zw.Create(name)
require.NoError(t, err)
}
require.NoError(t, zw.Close())
require.NoError(t, f.Close())
fromZip, err := newZipEntries(context.Background(), archivePath)
require.NoError(t, err)
var rooted []string
for _, name := range names {
rooted = append(rooted, "/"+name)
}
fromResolver := &resolverEntries{resolver: file.NewMockResolverForPaths(rooted...)}
for name, entries := range map[string]archiveEntries{"zip": fromZip, "resolver": fromResolver} {
t.Run(name, func(t *testing.T) {
assert.ElementsMatch(t, []string{"meta-inf/LICENSE.txt", "META-INF/MANIFEST.MF"}, entries.globFold("/META-INF/*"))
assert.Equal(t, []string{"Version.Properties"}, entries.globFold("/version.properties"))
assert.Equal(t, []string{"lib/DEP.JAR"}, entries.globFold("**/*.jar", "**/*.war"))
assert.Empty(t, entries.glob("/version.properties"), "glob itself stays case-sensitive")
})
}
}
+4 -4
View File
@@ -230,7 +230,7 @@ func (j *archiveParser) parse(ctx context.Context, parentPkg *pkg.Package) ([]pk
} else if !archive.NestedCatalogingEnabled(ctx) {
// nothing will catalog these, so record them as unknowns
// .jar and .war files are present in archives, are others? or generally just consider them top-level?
nestedArchives := j.entries.glob("**/*.jar", "**/*.war")
nestedArchives := j.entries.globFold("**/*.jar", "**/*.war")
if len(nestedArchives) > 0 {
slices.Sort(nestedArchives)
errs = unknown.Appendf(errs, j.location, "nested archives not cataloged: %v", strings.Join(nestedArchives, ", "))
@@ -606,7 +606,7 @@ func (j *archiveParser) getLicenseFromFileInArchive(ctx context.Context) []pkg.L
for _, glob := range []string{"/META-INF/*", "/*"} {
var licenseMatches []string
for _, f := range j.entries.glob(glob) {
for _, f := range j.entries.globFold(glob) {
if licenses.IsLicenseFile(path.Base(f)) {
licenseMatches = append(licenseMatches, f)
}
@@ -657,7 +657,7 @@ func (j *archiveParser) versionFromPropertiesFile(ctx context.Context, manifest
return ""
}
matches := j.entries.glob("/version.properties")
matches := j.entries.globFold("/version.properties")
if len(matches) != 1 {
return ""
}
@@ -700,7 +700,7 @@ func (j *archiveParser) discoverPkgsFromNestedArchives(ctx context.Context, pare
// associating each discovered package to the given parent package.
func discoverPkgsFromZip(ctx context.Context, location file.Location, archivePath, contentPath string, fileManifest intFile.ZipFileManifest, parentPkg *pkg.Package, cfg ArchiveCatalogerConfig) ([]pkg.Package, []artifact.Relationship, error) {
// search and parse pom.properties files & fetch the contents
openers, err := intFile.ExtractFromZipToUniqueTempFile(ctx, archivePath, contentPath, fileManifest.GlobMatch(archiveFormatGlobs...)...)
openers, err := intFile.ExtractFromZipToUniqueTempFile(ctx, archivePath, contentPath, fileManifest.GlobMatch(false, archiveFormatGlobs...)...)
if err != nil {
return nil, nil, fmt.Errorf("unable to extract files from zip: %w", err)
}