diff --git a/pkg/resolver/javascript.go b/pkg/resolver/javascript.go index 98c7a0f..123e668 100644 --- a/pkg/resolver/javascript.go +++ b/pkg/resolver/javascript.go @@ -7,12 +7,20 @@ import ( ) type JavaScriptResolver struct { - pnpmPathRe *regexp.Regexp + pnpmPathRe *regexp.Regexp + npmPackageJsonRe *regexp.Regexp + yarnCacheRe *regexp.Regexp } func NewJavaScriptResolver() *JavaScriptResolver { return &JavaScriptResolver{ + pnpmPathRe: regexp.MustCompile(`node_modules/\.pnpm/([^/]+)/node_modules/(@[^/]+/[^/]+|[^/]+)(?:/|$)`), + + npmPackageJsonRe: regexp.MustCompile(`node_modules/(@[^/]+/[^/]+|[^/]+)/package\.json$`), + + + yarnCacheRe: regexp.MustCompile(`\.yarn/cache/(.+)-npm-([0-9][^-]*)-[a-f0-9]+-[a-f0-9]+\.zip$`), } } @@ -31,7 +39,8 @@ func (r *JavaScriptResolver) Resolve(files []FileInfo) (packages []PackageInfo, continue } - name, version, ok := r.extractPnpmPackage(np) + + name, version, foundBy, ok := r.extractPackage(np) if !ok { remainingFiles = append(remainingFiles, f) continue @@ -51,7 +60,7 @@ func (r *JavaScriptResolver) Resolve(files []FileInfo) (packages []PackageInfo, Ecosystem: "npm", PURL: purl, Hashes: f.Hashes, - FoundBy: "attestation:javascript", + FoundBy: foundBy, } packages = append(packages, pkg) } @@ -59,6 +68,26 @@ func (r *JavaScriptResolver) Resolve(files []FileInfo) (packages []PackageInfo, return packages, remainingFiles } + +func (r *JavaScriptResolver) extractPackage(p string) (string, string, string, bool) { + + if name, version, ok := r.extractPnpmPackage(p); ok { + return name, version, "attestation:javascript:pnpm", true + } + + + if name, version, ok := r.extractYarnBerryPackage(p); ok { + return name, version, "attestation:javascript:yarn-berry", true + } + + + if name, ok := r.extractNpmPackage(p); ok { + return name, "unknown", "attestation:javascript:npm", true + } + + return "", "", "", false +} + func (r *JavaScriptResolver) CreateFileFilters(packages []PackageInfo) []PackageFileFilter { var filters []PackageFileFilter @@ -84,34 +113,62 @@ type jsPackageFilter struct { func (f *jsPackageFilter) Matches(p string) bool { np := path.Clean(p) npLower := strings.ToLower(np) + name := strings.ToLower(f.packageName) - if !strings.Contains(npLower, "/node_modules/.pnpm/") { + if name == "" { return false } - name := strings.ToLower(f.packageName) - ver := strings.ToLower(f.version) - if name == "" || ver == "" { - return false - } + + if strings.Contains(npLower, "/node_modules/.pnpm/") { + ver := strings.ToLower(f.version) + if ver == "" { + return false + } + + pnpmName := strings.ReplaceAll(name, "/", "+") + if strings.HasPrefix(pnpmName, "@") { + pnpmName = "@" + strings.TrimPrefix(pnpmName, "@") + } - pnpmName := strings.ReplaceAll(name, "/", "+") - if strings.HasPrefix(pnpmName, "@") { - pnpmName = "@" + strings.TrimPrefix(pnpmName, "@") + if strings.Contains(npLower, "/node_modules/.pnpm/"+pnpmName+"@"+ver) && + strings.Contains(npLower, "/node_modules/"+name+"/") { + return true + } } - if strings.Contains(npLower, "/node_modules/.pnpm/"+pnpmName+"@"+ver) && - strings.Contains(npLower, "/node_modules/"+name+"/") { + + if strings.Contains(npLower, "/node_modules/"+name+"/") && + !strings.Contains(npLower, "/node_modules/.pnpm/") { return true } + + if strings.Contains(npLower, "/.yarn/cache/") { + yarnName := yarnCacheName(name) + ver := strings.ToLower(f.version) + if ver != "" && ver != "unknown" { + if strings.Contains(npLower, "/"+yarnName+"-npm-"+ver+"-") { + return true + } + } + } + return false } + +func yarnCacheName(name string) string { + return strings.ReplaceAll(name, "/", "-") +} + func (r *JavaScriptResolver) isJavaScriptPath(p string) bool { - return strings.Contains(p, "node_modules") || strings.Contains(p, ".pnpm") + return strings.Contains(p, "node_modules") || + strings.Contains(p, ".pnpm") || + strings.Contains(p, ".yarn/cache") } + func (r *JavaScriptResolver) extractPnpmPackage(p string) (string, string, bool) { matches := r.pnpmPathRe.FindStringSubmatch(p) if len(matches) != 3 { @@ -128,6 +185,61 @@ func (r *JavaScriptResolver) extractPnpmPackage(p string) (string, string, bool) return name, version, true } + +func (r *JavaScriptResolver) extractNpmPackage(p string) (string, bool) { + + if strings.Contains(p, "node_modules/.pnpm") { + return "", false + } + + matches := r.npmPackageJsonRe.FindStringSubmatch(p) + if len(matches) != 2 { + return "", false + } + + name := matches[1] + + + if strings.HasPrefix(name, ".") { + return "", false + } + + return name, true +} + + +func (r *JavaScriptResolver) extractYarnBerryPackage(p string) (string, string, bool) { + matches := r.yarnCacheRe.FindStringSubmatch(p) + if len(matches) != 3 { + return "", "", false + } + + rawName := matches[1] + version := matches[2] + + name := decodeYarnCacheName(rawName) + + return name, version, true +} + + +func decodeYarnCacheName(raw string) string { + if !strings.HasPrefix(raw, "@") { + return raw + } + + + withoutAt := raw[1:] + idx := strings.Index(withoutAt, "-") + if idx == -1 { + return raw + } + + scope := withoutAt[:idx] + name := withoutAt[idx+1:] + return "@" + scope + "/" + name +} + func extractPnpmVersion(segment string) string { segment = strings.TrimSpace(segment) if segment == "" { diff --git a/pkg/resolver/javascript_test.go b/pkg/resolver/javascript_test.go new file mode 100644 index 0000000..c6cf628 --- /dev/null +++ b/pkg/resolver/javascript_test.go @@ -0,0 +1,427 @@ +package resolver + +import ( + "testing" +) + +// --- pnpm resolution (existing behavior, regression tests) --- + +func TestPnpmResolve(t *testing.T) { + r := NewJavaScriptResolver() + files := []FileInfo{ + {Path: "/home/user/project/node_modules/.pnpm/express@4.18.2/node_modules/express/index.js", Hashes: map[string]string{"sha256": "abc123"}}, + {Path: "/home/user/project/node_modules/.pnpm/express@4.18.2/node_modules/express/lib/router.js", Hashes: map[string]string{"sha256": "def456"}}, + } + + packages, remaining := r.Resolve(files) + + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].Name != "express" { + t.Errorf("expected name 'express', got %q", packages[0].Name) + } + if packages[0].Version != "4.18.2" { + t.Errorf("expected version '4.18.2', got %q", packages[0].Version) + } + if packages[0].PURL != "pkg:npm/express@4.18.2" { + t.Errorf("expected PURL 'pkg:npm/express@4.18.2', got %q", packages[0].PURL) + } + if packages[0].Ecosystem != "npm" { + t.Errorf("expected ecosystem 'npm', got %q", packages[0].Ecosystem) + } + if len(remaining) != 0 { + t.Errorf("expected 0 remaining files, got %d", len(remaining)) + } +} + +func TestPnpmScopedResolve(t *testing.T) { + r := NewJavaScriptResolver() + files := []FileInfo{ + {Path: "/home/user/project/node_modules/.pnpm/@babel+core@7.24.0/node_modules/@babel/core/lib/index.js", Hashes: map[string]string{"sha256": "abc"}}, + } + + packages, _ := r.Resolve(files) + + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].Name != "@babel/core" { + t.Errorf("expected name '@babel/core', got %q", packages[0].Name) + } + if packages[0].Version != "7.24.0" { + t.Errorf("expected version '7.24.0', got %q", packages[0].Version) + } +} + +func TestPnpmDeduplication(t *testing.T) { + r := NewJavaScriptResolver() + files := []FileInfo{ + {Path: "/project/node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/lodash.js", Hashes: map[string]string{"sha256": "a"}}, + {Path: "/project/node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/lodash.min.js", Hashes: map[string]string{"sha256": "b"}}, + {Path: "/project/node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/core.js", Hashes: map[string]string{"sha256": "c"}}, + } + + packages, _ := r.Resolve(files) + + if len(packages) != 1 { + t.Fatalf("expected 1 package (deduplicated), got %d", len(packages)) + } + if packages[0].Name != "lodash" { + t.Errorf("expected 'lodash', got %q", packages[0].Name) + } +} + +// --- Standard npm / Yarn classic resolution (new) --- + +func TestNpmStandardResolve(t *testing.T) { + r := NewJavaScriptResolver() + files := []FileInfo{ + {Path: "/home/user/project/node_modules/express/package.json", Hashes: map[string]string{"sha256": "abc"}}, + } + + packages, remaining := r.Resolve(files) + + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].Name != "express" { + t.Errorf("expected name 'express', got %q", packages[0].Name) + } + if packages[0].Version != "unknown" { + t.Errorf("expected version 'unknown', got %q", packages[0].Version) + } + if packages[0].PURL != "pkg:npm/express@unknown" { + t.Errorf("expected PURL 'pkg:npm/express@unknown', got %q", packages[0].PURL) + } + if packages[0].FoundBy != "attestation:javascript:npm" { + t.Errorf("expected foundBy 'attestation:javascript:npm', got %q", packages[0].FoundBy) + } + if len(remaining) != 0 { + t.Errorf("expected 0 remaining files, got %d", len(remaining)) + } +} + +func TestNpmScopedResolve(t *testing.T) { + r := NewJavaScriptResolver() + files := []FileInfo{ + {Path: "/project/node_modules/@babel/core/package.json", Hashes: map[string]string{"sha256": "abc"}}, + } + + packages, _ := r.Resolve(files) + + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].Name != "@babel/core" { + t.Errorf("expected name '@babel/core', got %q", packages[0].Name) + } + if packages[0].Version != "unknown" { + t.Errorf("expected version 'unknown', got %q", packages[0].Version) + } +} + +func TestNpmDoesNotMatchPnpmPaths(t *testing.T) { + r := NewJavaScriptResolver() + // This path should be matched by pnpm extractor (with version), NOT npm extractor + files := []FileInfo{ + {Path: "/project/node_modules/.pnpm/express@4.18.2/node_modules/express/package.json", Hashes: map[string]string{"sha256": "abc"}}, + } + + packages, _ := r.Resolve(files) + + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + // Should have the real version from pnpm, not "unknown" from npm extractor + if packages[0].Version != "4.18.2" { + t.Errorf("expected pnpm-extracted version '4.18.2', got %q (npm extractor may have matched instead)", packages[0].Version) + } + if packages[0].FoundBy != "attestation:javascript:pnpm" { + t.Errorf("expected foundBy 'attestation:javascript:pnpm', got %q", packages[0].FoundBy) + } +} + +func TestNpmSkipsHiddenPackages(t *testing.T) { + r := NewJavaScriptResolver() + files := []FileInfo{ + {Path: "/project/node_modules/.package-lock.json", Hashes: map[string]string{"sha256": "abc"}}, + {Path: "/project/node_modules/.cache/something/package.json", Hashes: map[string]string{"sha256": "def"}}, + } + + packages, _ := r.Resolve(files) + + if len(packages) != 0 { + t.Errorf("expected 0 packages (hidden dirs should be skipped), got %d", len(packages)) + } +} + +func TestNpmNonPackageJsonNotResolved(t *testing.T) { + r := NewJavaScriptResolver() + // Random files under node_modules should NOT be resolved (only package.json triggers detection) + files := []FileInfo{ + {Path: "/project/node_modules/express/lib/router.js", Hashes: map[string]string{"sha256": "abc"}}, + {Path: "/project/node_modules/express/index.js", Hashes: map[string]string{"sha256": "def"}}, + } + + packages, remaining := r.Resolve(files) + + if len(packages) != 0 { + t.Errorf("expected 0 packages (non-package.json files), got %d", len(packages)) + } + if len(remaining) != 2 { + t.Errorf("expected 2 remaining files, got %d", len(remaining)) + } +} + +func TestNpmMultiplePackages(t *testing.T) { + r := NewJavaScriptResolver() + files := []FileInfo{ + {Path: "/project/node_modules/express/package.json", Hashes: map[string]string{"sha256": "a"}}, + {Path: "/project/node_modules/lodash/package.json", Hashes: map[string]string{"sha256": "b"}}, + {Path: "/project/node_modules/@types/node/package.json", Hashes: map[string]string{"sha256": "c"}}, + } + + packages, _ := r.Resolve(files) + + if len(packages) != 3 { + t.Fatalf("expected 3 packages, got %d", len(packages)) + } + + names := map[string]bool{} + for _, pkg := range packages { + names[pkg.Name] = true + } + for _, expected := range []string{"express", "lodash", "@types/node"} { + if !names[expected] { + t.Errorf("expected package %q not found", expected) + } + } +} + +// --- Yarn Berry PnP resolution (new) --- + +func TestYarnBerryResolve(t *testing.T) { + r := NewJavaScriptResolver() + files := []FileInfo{ + {Path: "/project/.yarn/cache/react-npm-18.2.0-1eae08fee2-88b02f2e3e.zip", Hashes: map[string]string{"sha256": "abc"}}, + } + + packages, remaining := r.Resolve(files) + + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].Name != "react" { + t.Errorf("expected name 'react', got %q", packages[0].Name) + } + if packages[0].Version != "18.2.0" { + t.Errorf("expected version '18.2.0', got %q", packages[0].Version) + } + if packages[0].PURL != "pkg:npm/react@18.2.0" { + t.Errorf("expected PURL 'pkg:npm/react@18.2.0', got %q", packages[0].PURL) + } + if packages[0].FoundBy != "attestation:javascript:yarn-berry" { + t.Errorf("expected foundBy 'attestation:javascript:yarn-berry', got %q", packages[0].FoundBy) + } + if len(remaining) != 0 { + t.Errorf("expected 0 remaining files, got %d", len(remaining)) + } +} + +func TestYarnBerryScopedResolve(t *testing.T) { + r := NewJavaScriptResolver() + files := []FileInfo{ + {Path: "/project/.yarn/cache/@types-node-npm-20.11.5-b807d46a42-6e3487cf0f.zip", Hashes: map[string]string{"sha256": "abc"}}, + } + + packages, _ := r.Resolve(files) + + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].Name != "@types/node" { + t.Errorf("expected name '@types/node', got %q", packages[0].Name) + } + if packages[0].Version != "20.11.5" { + t.Errorf("expected version '20.11.5', got %q", packages[0].Version) + } +} + +func TestYarnBerryMultiWordScoped(t *testing.T) { + r := NewJavaScriptResolver() + files := []FileInfo{ + // @tanstack/react-query → @tanstack-react-query in yarn cache + {Path: "/project/.yarn/cache/@tanstack-react-query-npm-5.17.9-aabb112233-ccdd445566.zip", Hashes: map[string]string{"sha256": "abc"}}, + } + + packages, _ := r.Resolve(files) + + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].Name != "@tanstack/react-query" { + t.Errorf("expected name '@tanstack/react-query', got %q", packages[0].Name) + } + if packages[0].Version != "5.17.9" { + t.Errorf("expected version '5.17.9', got %q", packages[0].Version) + } +} + +// --- Package filter tests --- + +func TestJsPackageFilterPnpm(t *testing.T) { + filter := &jsPackageFilter{packageName: "express", version: "4.18.2"} + + // Should match files under the pnpm store for this package + if !filter.Matches("/project/node_modules/.pnpm/express@4.18.2/node_modules/express/lib/router.js") { + t.Error("expected pnpm path to match") + } + + // Should not match a different version + if filter.Matches("/project/node_modules/.pnpm/express@4.17.0/node_modules/express/lib/router.js") { + t.Error("expected different version pnpm path to NOT match") + } +} + +func TestJsPackageFilterNpm(t *testing.T) { + filter := &jsPackageFilter{packageName: "express", version: "unknown"} + + // Should match standard npm paths + if !filter.Matches("/project/node_modules/express/lib/router.js") { + t.Error("expected standard npm path to match") + } + if !filter.Matches("/project/node_modules/express/index.js") { + t.Error("expected standard npm path to match") + } + + // Should not match .pnpm internal paths (those have their own filter logic) + if filter.Matches("/project/node_modules/.pnpm/express@4.18.2/node_modules/express/index.js") { + t.Error("expected pnpm internal path to NOT match via npm filter") + } +} + +func TestJsPackageFilterNpmScoped(t *testing.T) { + filter := &jsPackageFilter{packageName: "@babel/core", version: "unknown"} + + if !filter.Matches("/project/node_modules/@babel/core/lib/index.js") { + t.Error("expected scoped npm path to match") + } +} + +func TestJsPackageFilterYarnBerry(t *testing.T) { + filter := &jsPackageFilter{packageName: "react", version: "18.2.0"} + + if !filter.Matches("/project/.yarn/cache/react-npm-18.2.0-1eae08fee2-88b02f2e3e.zip") { + t.Error("expected yarn berry cache path to match") + } + + // Should not match a different version + if filter.Matches("/project/.yarn/cache/react-npm-17.0.1-deadbeef12-abcdef1234.zip") { + t.Error("expected different version yarn berry path to NOT match") + } +} + +// --- Mixed ecosystem test --- + +func TestMixedJsEcosystems(t *testing.T) { + r := NewJavaScriptResolver() + files := []FileInfo{ + // pnpm package + {Path: "/project/node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/lodash.js", Hashes: map[string]string{"sha256": "a"}}, + // npm package + {Path: "/project/node_modules/express/package.json", Hashes: map[string]string{"sha256": "b"}}, + // yarn berry package + {Path: "/project/.yarn/cache/react-npm-18.2.0-1eae08fee2-88b02f2e3e.zip", Hashes: map[string]string{"sha256": "c"}}, + // Non-JS file + {Path: "/project/src/main.go", Hashes: map[string]string{"sha256": "d"}}, + } + + packages, remaining := r.Resolve(files) + + if len(packages) != 3 { + t.Fatalf("expected 3 packages, got %d", len(packages)) + } + if len(remaining) != 1 { + t.Fatalf("expected 1 remaining file, got %d", len(remaining)) + } + if remaining[0].Path != "/project/src/main.go" { + t.Errorf("expected remaining file to be main.go, got %q", remaining[0].Path) + } + + // Verify each package manager was used + foundByMap := map[string]bool{} + for _, pkg := range packages { + foundByMap[pkg.FoundBy] = true + } + for _, expected := range []string{ + "attestation:javascript:pnpm", + "attestation:javascript:npm", + "attestation:javascript:yarn-berry", + } { + if !foundByMap[expected] { + t.Errorf("expected foundBy %q not found", expected) + } + } +} + +// --- Helper function tests --- + +func TestNormalizeNpmPackageName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"Express", "express"}, + {" lodash ", "lodash"}, + {"@Babel/Core", "@babel/core"}, + {"REACT", "react"}, + } + + for _, tt := range tests { + result := NormalizeNpmPackageName(tt.input) + if result != tt.expected { + t.Errorf("NormalizeNpmPackageName(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestDecodeYarnCacheName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"react", "react"}, + {"lodash", "lodash"}, + {"@types-node", "@types/node"}, + {"@babel-core", "@babel/core"}, + {"@tanstack-react-query", "@tanstack/react-query"}, + } + + for _, tt := range tests { + result := decodeYarnCacheName(tt.input) + if result != tt.expected { + t.Errorf("decodeYarnCacheName(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestExtractPnpmVersion(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"express@4.18.2", "4.18.2"}, + {"@babel+core@7.24.0", "7.24.0"}, + {"react@18.2.0(react-dom@18.2.0)", "18.2.0"}, + {"", ""}, + {"no-version", ""}, + } + + for _, tt := range tests { + result := extractPnpmVersion(tt.input) + if result != tt.expected { + t.Errorf("extractPnpmVersion(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} diff --git a/pkg/resolver/python_test.go b/pkg/resolver/python_test.go new file mode 100644 index 0000000..cc7d786 --- /dev/null +++ b/pkg/resolver/python_test.go @@ -0,0 +1,210 @@ +package resolver + +import ( + "testing" +) + +func TestPythonDistInfoResolve(t *testing.T) { + r := NewPythonResolver() + files := []FileInfo{ + {Path: "/usr/lib/python3.11/site-packages/requests-2.31.0.dist-info/METADATA", Hashes: map[string]string{"sha256": "abc123"}}, + {Path: "/usr/lib/python3.11/site-packages/requests-2.31.0.dist-info/RECORD", Hashes: map[string]string{"sha256": "def456"}}, + } + + packages, remaining := r.Resolve(files) + + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].Name != "requests" { + t.Errorf("expected name 'requests', got %q", packages[0].Name) + } + if packages[0].Version != "2.31.0" { + t.Errorf("expected version '2.31.0', got %q", packages[0].Version) + } + if packages[0].PURL != "pkg:pypi/requests@2.31.0" { + t.Errorf("expected PURL 'pkg:pypi/requests@2.31.0', got %q", packages[0].PURL) + } + if packages[0].Ecosystem != "pypi" { + t.Errorf("expected ecosystem 'pypi', got %q", packages[0].Ecosystem) + } + if packages[0].FoundBy != "attestation:python" { + t.Errorf("expected foundBy 'attestation:python', got %q", packages[0].FoundBy) + } + if len(remaining) != 0 { + t.Errorf("expected 0 remaining files, got %d", len(remaining)) + } +} + +func TestPythonEggInfoResolve(t *testing.T) { + r := NewPythonResolver() + files := []FileInfo{ + {Path: "/usr/lib/python3.11/site-packages/setuptools-69.0.3.egg-info/PKG-INFO", Hashes: map[string]string{"sha256": "abc"}}, + } + + packages, _ := r.Resolve(files) + + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].Name != "setuptools" { + t.Errorf("expected name 'setuptools', got %q", packages[0].Name) + } + if packages[0].Version != "69.0.3" { + t.Errorf("expected version '69.0.3', got %q", packages[0].Version) + } +} + +func TestPythonDistPackagesResolve(t *testing.T) { + r := NewPythonResolver() + files := []FileInfo{ + {Path: "/usr/lib/python3/dist-packages/certifi-2023.7.22.dist-info/METADATA", Hashes: map[string]string{"sha256": "abc"}}, + } + + packages, _ := r.Resolve(files) + + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].Name != "certifi" { + t.Errorf("expected name 'certifi', got %q", packages[0].Name) + } +} + +func TestPythonNameNormalization(t *testing.T) { + r := NewPythonResolver() + files := []FileInfo{ + // Underscores in package name should be normalized to hyphens (PEP 503) + {Path: "/env/lib/python3.11/site-packages/Flask_Cors-4.0.0.dist-info/METADATA", Hashes: map[string]string{"sha256": "abc"}}, + } + + packages, _ := r.Resolve(files) + + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].Name != "flask-cors" { + t.Errorf("expected normalized name 'flask-cors', got %q", packages[0].Name) + } +} + +func TestPythonDeduplication(t *testing.T) { + r := NewPythonResolver() + files := []FileInfo{ + {Path: "/env/lib/python3.11/site-packages/werkzeug-3.0.1.dist-info/METADATA", Hashes: map[string]string{"sha256": "a"}}, + {Path: "/env/lib/python3.11/site-packages/werkzeug-3.0.1.dist-info/RECORD", Hashes: map[string]string{"sha256": "b"}}, + {Path: "/env/lib/python3.11/site-packages/werkzeug-3.0.1.dist-info/top_level.txt", Hashes: map[string]string{"sha256": "c"}}, + {Path: "/env/lib/python3.11/site-packages/werkzeug-3.0.1.dist-info/WHEEL", Hashes: map[string]string{"sha256": "d"}}, + } + + packages, _ := r.Resolve(files) + + if len(packages) != 1 { + t.Fatalf("expected 1 package (deduplicated), got %d", len(packages)) + } +} + +func TestPythonMultiplePackages(t *testing.T) { + r := NewPythonResolver() + files := []FileInfo{ + {Path: "/env/lib/python3.11/site-packages/flask-3.0.0.dist-info/METADATA", Hashes: map[string]string{"sha256": "a"}}, + {Path: "/env/lib/python3.11/site-packages/werkzeug-3.0.1.dist-info/METADATA", Hashes: map[string]string{"sha256": "b"}}, + {Path: "/env/lib/python3.11/site-packages/jinja2-3.1.2.dist-info/METADATA", Hashes: map[string]string{"sha256": "c"}}, + } + + packages, _ := r.Resolve(files) + + if len(packages) != 3 { + t.Fatalf("expected 3 packages, got %d", len(packages)) + } + + names := map[string]bool{} + for _, pkg := range packages { + names[pkg.Name] = true + } + for _, expected := range []string{"flask", "werkzeug", "jinja2"} { + if !names[expected] { + t.Errorf("expected package %q not found", expected) + } + } +} + +func TestPythonNonPythonFilesPassThrough(t *testing.T) { + r := NewPythonResolver() + files := []FileInfo{ + {Path: "/usr/lib/python3.11/site-packages/requests-2.31.0.dist-info/METADATA", Hashes: map[string]string{"sha256": "a"}}, + {Path: "/home/user/project/main.go", Hashes: map[string]string{"sha256": "b"}}, + {Path: "/home/user/project/src/app.rs", Hashes: map[string]string{"sha256": "c"}}, + } + + packages, remaining := r.Resolve(files) + + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if len(remaining) != 2 { + t.Fatalf("expected 2 remaining files, got %d", len(remaining)) + } +} + +// --- Package filter tests --- + +func TestPythonPackageFilterSitePackages(t *testing.T) { + filter := &pythonPackageFilter{packageName: "werkzeug", version: "3.0.1"} + + // Should match files under the package directory + if !filter.Matches("/env/lib/python3.11/site-packages/werkzeug/routing/rules.py") { + t.Error("expected werkzeug source file to match") + } + if !filter.Matches("/env/lib/python3.11/site-packages/werkzeug/__init__.py") { + t.Error("expected werkzeug init file to match") + } + if !filter.Matches("/env/lib/python3.11/site-packages/werkzeug-3.0.1.dist-info/METADATA") { + t.Error("expected werkzeug dist-info to match") + } + + // Should NOT match unrelated packages + if filter.Matches("/env/lib/python3.11/site-packages/flask/app.py") { + t.Error("expected flask file to NOT match werkzeug filter") + } +} + +func TestPythonPackageFilterDistPackages(t *testing.T) { + filter := &pythonPackageFilter{packageName: "certifi", version: "2023.7.22"} + + if !filter.Matches("/usr/lib/python3/dist-packages/certifi/core.py") { + t.Error("expected dist-packages path to match") + } +} + +func TestPythonPackageFilterPrivateModule(t *testing.T) { + // pytest installs as _pytest internally + filter := &pythonPackageFilter{packageName: "pytest", version: "7.4.3"} + + if !filter.Matches("/env/lib/python3.11/site-packages/_pytest/config/__init__.py") { + t.Error("expected _pytest (private variant) to match pytest filter") + } +} + +// --- NormalizePackageName tests --- + +func TestNormalizePackageName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"Flask", "flask"}, + {"Flask_Cors", "flask-cors"}, + {"Jinja2", "jinja2"}, + {"my__package", "my-package"}, + {"UPPER_CASE", "upper-case"}, + {"already-normalized", "already-normalized"}, + } + + for _, tt := range tests { + result := NormalizePackageName(tt.input) + if result != tt.expected { + t.Errorf("NormalizePackageName(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +}