Skip to content

Commit 652294a

Browse files
kotakanbeclaude
andauthored
deps: remove fanal framework, call Trivy parsers directly (#2476)
* test: add golden tests for AnalyzeLibrary Add golden tests that snapshot the output of AnalyzeLibrary() for all 35 lockfile fixtures in integration/data/lockfile/. This establishes a regression baseline before refactoring the Trivy fanal integration. - Covers all supported languages: npm, yarn, pnpm, bun, pip, pipenv, poetry, uv, bundler, cargo, composer, go mod/sum/binary, pom.xml, gradle, JAR/WAR, NuGet, conan, pubspec, mix, cocoapods, swift - Results are sorted by name+version for deterministic comparison - Run with -update flag to regenerate: go test ./scanner/... -args -update Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: extract file-to-parser dispatch function Extract detectParserType() that maps file paths to parser types, replicating the logic of each Trivy fanal analyzer's Required() method. This is a preparatory step for removing the fanal framework dependency. No changes to existing behavior — the function is not yet called from production code. Covers all edge cases: - node_modules/ and .yarn/ exclusions for npm/yarn/pnpm - Case-insensitive matching for .NET packages.props and JAR extensions - Suffix matching for gradle.lockfile and .deps.json - Executable filemode detection for Go/Rust binaries Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * deps: remove fanal framework, call Trivy parsers directly Replace the Trivy fanal analyzer framework with direct calls to dependency parsers, eliminating the IaC/misconf package tree that was pulled in transitively. Changes: - Rewrite AnalyzeLibrary() to use detectParserType() + direct parser calls - Remove all fanal blank imports (25+ analyzer registrations) - Remove fanal analyzer framework usage (NewAnalyzerGroup, AnalyzeFile, etc.) - Simplify JAR parser to standalone function, removing fanal PostAnalyzer - Remove DummyFileInfo (no longer needed) Scanner-only binary: 191 MB -> 46 MB (-76%) Trivy deps: 352 -> 144 (-59%) misconf packages: 1 -> 0 All golden tests pass. Both scanner-only and full builds verified. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: fix goimports formatting and go mod tidy - Fix goimports formatting in scanner/base.go, dispatch.go, dispatch_test.go - golang.org/x/sync moved from direct to indirect (errgroup no longer used) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: address Copilot review on golden tests - Skip test when integration/ submodule is not checked out (t.Skip with instructions to init submodule) - Use filepath.ToSlash for cross-platform golden file naming Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: copy text fixtures to testdata for CI compatibility Copy lockfile fixtures from integration submodule to scanner/testdata/fixtures/ so golden tests run in CI without requiring submodule checkout. Binary fixtures (JAR, WAR, Go/Rust binaries) remain in the integration submodule and are skipped when not available. CI coverage: 29/34 tests run (5 binary fixtures skipped) Local coverage: 34/34 tests run (with submodule initialized) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: force-add requirements.txt ignored by *.txt gitignore The repo-level .gitignore has *.txt which prevented requirements.txt from being committed. Use git add -f to override. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Copilot review on PR #2476 - Return parse errors from AnalyzeLibrary instead of silently swallowing them. Caller (scanLibraries) logs warning and continues to next file. - Use io.SeekEnd/io.SeekStart instead of magic numbers in JAR parser. - Golden test treats parse errors as empty result (matches production behavior where scanLibraries warns and continues). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Copilot review round 2 on PR #2476 - Remove unused parserRustBinary constant (dispatch returns parserGoBinary for all executables, language determined at parse time) - Make suffix-based dispatch case-insensitive (.deps.json, gradle.lockfile) for cross-platform compatibility - Aggregate parse errors into l.warns so they surface in scan results - Remove unused fanal blank imports from golden test file Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Copilot review round 3 - Collect parse errors in l.errs (ScanResult.Errors) with comments - Rename parserGoBinary to parserExecutable (value "executable") - Add expectParseError to golden test entries Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add diff-lockfile for A/B regression testing Add scripts/compare-lockfile.go and scripts/lockfile-fixtures.json for comparing AnalyzeLibrary output between Git refs using real-world lockfiles from popular OSS projects. 17 fixtures: npm, yarn, pnpm, pip, pipenv, poetry, bundler, cargo, composer, go.mod, pom.xml, mix, swift, JAR (x2), Go binary, Rust binary Supports tar.gz extraction for binary fixtures and custom filemodes. Add diff-lockfile target to GNUmakefile: make diff-lockfile # compare against master make diff-lockfile BASE=commit # compare against specific ref Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: gitignore compare-lockfile binary Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add scripts/README.md for diff-lockfile usage Document when and how to run the lockfile regression test. Also gitignore the compare-lockfile binary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Copilot review round 4 on PR #2476 scanner/base.go: - Add Rust binary error types (ErrUnrecognizedExe, ErrNonRustBinary) to parseBinary suppression list - Wrap parse errors with file path context in l.errs scripts/compare-lockfile.go: - Fix file header (compare-analyze.go -> compare-lockfile.go) - Add HTTP client timeout (5 min) - Remove dead code in base runner safe variable Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update fixtures to non-zero libs and fix docs Fixtures: - pip: home-assistant/core (43 libs, was django 0 libs) - pom: apache/spark (2 libs, was apache/maven 0 libs) - rustbinary: cargo-bins/cargo-binstall (399 libs, built with cargo-auditable, was sharkdp/fd 0 libs) - Fix .tgz archive extraction scanner/base.go: - Add Rust binary error types to parseBinary suppression - Wrap parse errors with file path context scripts/compare-lockfile.go: - Fix file header, add HTTP timeout, remove dead code, support .tgz Update scripts/README.md and PR description to match current fixtures. Result: 17/17 IDENTICAL across 7,194 libraries, all non-zero. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: surface all filesystem errors in compare-lockfile - Check copyFile return when copying fixtures to worktree - Check MkdirAll and WriteFile for base_runner.go setup - Fix MkdirAll error in copyFile itself All errors now logged and cause runOnBase to return nil, which triggers the skipped > 0 exit condition in main. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add complete sort tie-breakers and harden error handling Add Dev field to sort comparators for deterministic ordering across all normalize functions (main script, runner, golden test). Fix os.MkdirAll error handling in main() and runner. Replace custom replaceAll/indexOf with strings.ReplaceAll. Handle diff command errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: sort imports per gofmt and sanitize worktree path - Reorder imports in base.go and compare-lockfile.go to match gofmt conventions (stdlib alphabetical, then third-party groups) - Sanitize baseRef in worktree directory name to handle branch names containing slashes (e.g. feature/foo → feature_foo) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use MkdirTemp for worktree and handle result-counting errors - Replace os.RemoveAll with os.MkdirTemp for worktree directory creation to avoid path traversal risks from unsanitized baseRef - Handle errors from os.ReadDir, os.ReadFile, and json.Unmarshal in result-counting block so failures are diagnosable Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: check os.Remove and worktree cleanup errors Handle errors from os.Remove (temp dir removal before git worktree add) and git worktree remove (deferred cleanup) to avoid silent failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use abspath instead of trivypath in error messages Error messages in ScanResult.Errors are user-facing. Use the original filesystem path (abspath) so users can locate the failing lockfile, not the Trivy-internal cleaned path (trivypath). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: use workdir-relative path in README instead of hardcoded /tmp Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address remaining code quality issues - Use io.SeekStart instead of magic number 0 in parseExecutableBinary - Ensure parent directory exists in newLogger before creating log file - Sanitize path separators and ".." in safeFilename (main and runner) - Use http.StatusOK instead of magic number 200 - Use os.TempDir() for default workdir instead of hardcoded /tmp - Return error from logger.close() instead of discarding it - Include HTTP status text in fetch error messages - Update README to use $TMPDIR instead of /tmp/diet-compare Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: fix result map key and add diff fallback - Fix TrimSuffix to trim ".result.json" instead of ".json" so the result map keys match safeFilename() output - Use exec.LookPath to detect diff availability and fall back to printing file paths when diff is not installed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: comprehensive code quality sweep - Handle deferred log.close() error instead of discarding - Fix io.Copy + defer Close pattern to not lose write errors in fetchFixture and extractFromTarGz - Add os.Args bounds check in generated runner - Hoist strings.NewReplacer to package var (pathSanitizer) and outside loop in runner - Distinguish parse error from OK in log output (main + runner) - Fix result map key to trim ".result.json" not ".json" - Add diff command fallback when diff is not installed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add ComposerVendor dispatch and complete test coverage Add installed.json (ComposerInstalledJson) to detectParserType as parserComposerVendor, preventing regression from fanal removal. Verified all FindLockFiles entries are covered by detectParserType. - dispatch.go/base.go: add parserComposerVendor using same Composer parser with ComposerVendor type - dispatch_test.go: add installed.json test cases - Golden test: add installed.json fixture and golden file Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add ComposerVendor dispatch, fix lint, use request context - Add installed.json (ComposerInstalledJson) to detectParserType as parserComposerVendor, preventing regression from fanal removal - Fix goimports alignment in dispatch.go const block - Use http.NewRequestWithContext in fetchFixture for cancellation support - Add golden test fixture and dispatch test cases for installed.json Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: make diff-lockfile fetch optional via FETCH variable Default FETCH=1 (download fixtures). Use FETCH=0 to re-run comparison with cached fixtures, avoiding repeated network downloads during development iterations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: fix README wording to match actual script behavior The script generates a runner and executes it via go run, not a separate build step. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: detect .exe and exclude non-regular files in isExecutable Mimic Trivy's utils.IsExecutable: check .exe extension for Windows, exclude directories/symlinks, then check Unix execute bits. Fixes Windows .exe binaries not being scanned. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback (rename, typo, comments, unused arg) - Rename diff-lockfile to compare-lockfile in GNUmakefile and README - Fix typo: Text -> Test in analyze_golden_test.go - Add comment explaining why lockfileParser is defined locally - Remove unused fixtures parameter from runOnBase Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add security rationale for not using submodules in CI Explain the concrete attack scenario (fork PR rewriting .gitmodules) to document why binary fixture tests are local-only. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: expand diff-lockfile to 129 fixtures, remove unused jar method - Expand lockfile-fixtures.json from 17 to 129 entries (10 per ecosystem) - Replace all 0-libs fixtures with projects that have actual dependencies - pipenv reduced to 5 fixtures (Pipfile.lock is extremely rare in OSS) - Add pom.xml online mode golden file for TestAnalyzeLibrary_PomOnline - Remove unused properties.string() method in jar parser (Trivy fork code) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: add /compare-lockfile binary to .gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: fix goimports alignment in dispatch_test.go Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: check IsRegular before .exe in isExecutable Move non-regular file check before .exe extension check so that directories/symlinks ending in .exe are excluded. Allow mode==0 for Windows compatibility (stat returns 0 permission bits). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: update fixture count in scripts/README.md (17 → 129) * test: compare lib count instead of byte length in pom online test * fix: unmarshal golden JSON as slice in PomOnline test * fix: address MaineK00n review feedback - Rename golden files: .golden.json → .json (redundant under testdata/golden/) - Use cmp.Or for sort comparators in normalizeResult - Switch on langType in parseBinary to isolate sentinel errors per language - Wrap parseExecutableBinary errors with language name, symmetrize structure - Simplify dispatch.go: inline filepath.Base in switch, use slices.Contains - Extract base-runner.go from compare-lockfile.go via //go:embed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b9ac2ff commit 652294a

File tree

81 files changed

+85412
-287
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

81 files changed

+85412
-287
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,5 @@ vuls
2323
/trivy-to-vuls
2424
snmp2cpe
2525
!snmp2cpe/
26+
/scripts/compare-lockfile
27+
/compare-lockfile

GNUmakefile

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
pretest \
1111
test \
1212
cov \
13-
clean
13+
clean \
14+
compare-lockfile
1415

1516
SRCS = $(shell git ls-files '*.go')
1617
PKGS = $(shell go list ./...)
@@ -241,6 +242,17 @@ define sed-d
241242
find ${ONE_SEC_AFTER_JSON_DIR} -type f -exec sed -i -e '/scannedRevision/d' {} \;
242243
endef
243244

245+
# Compare AnalyzeLibrary output between current branch and BASE ref.
246+
# Fetches real-world lockfiles from popular OSS projects and compares results.
247+
# Usage:
248+
# make compare-lockfile # fetch fixtures and compare against master
249+
# make compare-lockfile BASE=commit # compare against specific ref
250+
# make compare-lockfile FETCH=0 # re-run with cached fixtures (skip download)
251+
BASE ?= master
252+
FETCH ?= 1
253+
compare-lockfile:
254+
$(GO) run scripts/compare-lockfile.go $(if $(filter 1,$(FETCH)),-fetch) -base $(BASE)
255+
244256
define count-cve
245257
for jsonfile in ${NOW_JSON_DIR}/*.json ; do \
246258
echo $$jsonfile; cat $$jsonfile | jq ".scannedCves | length" ; \

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ require (
5959
github.com/vulsio/gost v0.7.2
6060
go.etcd.io/bbolt v1.4.3
6161
golang.org/x/oauth2 v0.35.0
62-
golang.org/x/sync v0.20.0
6362
golang.org/x/term v0.40.0
6463
golang.org/x/text v0.34.0
6564
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
@@ -347,6 +346,7 @@ require (
347346
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
348347
golang.org/x/mod v0.33.0 // indirect
349348
golang.org/x/net v0.51.0 // indirect
349+
golang.org/x/sync v0.20.0 // indirect
350350
golang.org/x/sys v0.41.0 // indirect
351351
golang.org/x/time v0.14.0 // indirect
352352
golang.org/x/tools v0.42.0 // indirect

scanner/analyze_golden_test.go

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
package scanner
2+
3+
import (
4+
"cmp"
5+
"context"
6+
"encoding/json"
7+
"flag"
8+
"os"
9+
"path/filepath"
10+
"slices"
11+
"strings"
12+
"testing"
13+
14+
"github.com/future-architect/vuls/models"
15+
)
16+
17+
var update = flag.Bool("update", false, "update golden files")
18+
19+
// lockfileEntry defines a test fixture for AnalyzeLibrary golden testing.
20+
type lockfileEntry struct {
21+
// path is the relative path from the fixtures directory.
22+
path string
23+
// filemode to pass to AnalyzeLibrary (0755 for executables, 0644 otherwise).
24+
filemode os.FileMode
25+
// binary indicates the fixture is a binary file only available in the
26+
// integration submodule (not copied to testdata/fixtures/).
27+
binary bool
28+
// expectParseError indicates this fixture is known to produce a parse error
29+
// (e.g. unsupported lockfile version). The test treats errors as empty result.
30+
expectParseError bool
31+
}
32+
33+
var lockfiles = []lockfileEntry{
34+
// Node.js
35+
{"npm-v1/package-lock.json", 0644, false, false},
36+
{"npm-v2/package-lock.json", 0644, false, false},
37+
{"npm-v3/package-lock.json", 0644, false, false},
38+
{"yarn.lock", 0644, false, false},
39+
{"pnpm/pnpm-lock.yaml", 0644, false, true}, // pnpm v8: known parse error
40+
{"pnpm-v9/pnpm-lock.yaml", 0644, false, false},
41+
{"bun.lock", 0644, false, false},
42+
43+
// Python
44+
{"requirements.txt", 0644, false, false},
45+
{"Pipfile.lock", 0644, false, false},
46+
{"poetry-v1/poetry.lock", 0644, false, false},
47+
{"poetry-v2/poetry.lock", 0644, false, false},
48+
{"uv.lock", 0644, false, false},
49+
50+
// Ruby
51+
{"Gemfile.lock", 0644, false, false},
52+
53+
// Rust
54+
{"Cargo.lock", 0644, false, false},
55+
{"hello-rust", 0755, true, false},
56+
57+
// PHP
58+
{"composer.lock", 0644, false, false},
59+
{"installed.json", 0644, false, false},
60+
61+
// Go
62+
{"go.mod", 0644, false, false},
63+
{"go.sum", 0644, false, false},
64+
{"gobinary", 0755, true, false},
65+
66+
// Java
67+
{"pom.xml", 0644, false, false},
68+
{"gradle.lockfile", 0644, false, false},
69+
{"log4j-core-2.13.0.jar", 0644, true, false},
70+
{"wrong-name-log4j-core.jar", 0644, true, false},
71+
{"juddiv3-war-3.3.5.war", 0644, true, false},
72+
73+
// .NET
74+
{"packages.lock.json", 0644, false, false},
75+
{"packages.config", 0644, false, false},
76+
{"datacollector.deps.json", 0644, false, false},
77+
{"Directory.Packages.props", 0644, false, false},
78+
79+
// C/C++
80+
{"conan-v1/conan.lock", 0644, false, false},
81+
{"conan-v2/conan.lock", 0644, false, false},
82+
83+
// Dart
84+
{"pubspec.lock", 0644, false, false},
85+
86+
// Elixir
87+
{"mix.lock", 0644, false, false},
88+
89+
// Swift
90+
{"Podfile.lock", 0644, false, false},
91+
{"Package.resolved", 0644, false, false},
92+
}
93+
94+
// goldenFileName converts a lockfile path to a golden file name.
95+
// e.g. "npm-v3/package-lock.json" -> "npm-v3_package-lock.json"
96+
// Uses filepath.ToSlash to normalize path separators across platforms.
97+
func goldenFileName(lockfilePath string) string {
98+
return strings.ReplaceAll(filepath.ToSlash(lockfilePath), "/", "_") + ".json"
99+
}
100+
101+
func TestAnalyzeLibrary_Golden(t *testing.T) {
102+
fixturesDir := filepath.Join("testdata", "fixtures")
103+
integrationDir := filepath.Join("..", "integration", "data", "lockfile")
104+
goldenDir := filepath.Join("testdata", "golden")
105+
106+
for _, lf := range lockfiles {
107+
t.Run(lf.path, func(t *testing.T) {
108+
// Test fixtures are in testdata/fixtures/ (committed to repo).
109+
// Binary fixtures (JAR, WAR, Go/Rust binaries) are only in the
110+
// integration submodule — skip if not available.
111+
// NOTE: We intentionally do NOT add submodules: true to CI checkout.
112+
// Attack scenario: an attacker forks this repo, edits .gitmodules to
113+
// replace the integration submodule URL with their own repo containing
114+
// a malicious go.mod or _test.go, then opens a PR. If CI checks out
115+
// submodules, `go test` executes attacker-controlled code with access
116+
// to the CI environment (secrets, GITHUB_TOKEN, network).
117+
// Binary fixture tests therefore run locally only.
118+
srcPath := filepath.Join(fixturesDir, lf.path)
119+
if lf.binary {
120+
srcPath = filepath.Join(integrationDir, lf.path)
121+
}
122+
contents, err := os.ReadFile(srcPath)
123+
if err != nil {
124+
if lf.binary {
125+
t.Skipf("Binary fixture not found: %s (requires: git submodule update --init)", srcPath)
126+
}
127+
t.Fatalf("Failed to read %s: %v", srcPath, err)
128+
}
129+
130+
got, err := AnalyzeLibrary(context.Background(), lf.path, contents, lf.filemode, true)
131+
if err != nil {
132+
if lf.expectParseError {
133+
// Verify the error is actually a parse error (contains "parse error" or the parser type)
134+
errMsg := err.Error()
135+
if !strings.Contains(errMsg, "parse error") && !strings.Contains(errMsg, "Failed to parse") {
136+
t.Fatalf("AnalyzeLibrary(%s) expected parse error but got: %v", lf.path, err)
137+
}
138+
t.Logf("AnalyzeLibrary(%s) returned expected parse error: %v", lf.path, err)
139+
got = nil
140+
} else {
141+
t.Fatalf("AnalyzeLibrary(%s) unexpected error: %v", lf.path, err)
142+
}
143+
}
144+
145+
gotJSON, err := json.MarshalIndent(normalizeResult(got), "", " ")
146+
if err != nil {
147+
t.Fatalf("Failed to marshal result: %v", err)
148+
}
149+
150+
goldenPath := filepath.Join(goldenDir, goldenFileName(lf.path))
151+
152+
if *update {
153+
if err := os.MkdirAll(goldenDir, 0755); err != nil {
154+
t.Fatalf("Failed to create golden dir: %v", err)
155+
}
156+
if err := os.WriteFile(goldenPath, gotJSON, 0644); err != nil {
157+
t.Fatalf("Failed to write golden file: %v", err)
158+
}
159+
t.Logf("Updated golden file: %s", goldenPath)
160+
return
161+
}
162+
163+
wantJSON, err := os.ReadFile(goldenPath)
164+
if err != nil {
165+
t.Fatalf("Golden file not found: %s (run with -update to generate)", goldenPath)
166+
}
167+
168+
if string(gotJSON) != string(wantJSON) {
169+
t.Errorf("AnalyzeLibrary(%s) output differs from golden file.\nGot:\n%s\nWant:\n%s",
170+
lf.path, string(gotJSON), string(wantJSON))
171+
}
172+
})
173+
}
174+
}
175+
176+
// TestAnalyzeLibrary_PomOnline verifies that pom.xml parsing in online mode
177+
// (resolving transitive dependencies from Maven Central) works correctly.
178+
// Skipped with -short since it requires network access.
179+
func TestAnalyzeLibrary_PomOnline(t *testing.T) {
180+
if testing.Short() {
181+
t.Skip("skipping online pom.xml test (requires network access)")
182+
}
183+
184+
fixturesDir := filepath.Join("testdata", "fixtures")
185+
goldenDir := filepath.Join("testdata", "golden")
186+
187+
contents, err := os.ReadFile(filepath.Join(fixturesDir, "pom.xml"))
188+
if err != nil {
189+
t.Fatalf("Failed to read pom.xml: %v", err)
190+
}
191+
192+
got, err := AnalyzeLibrary(context.Background(), "pom.xml", contents, 0644, false)
193+
if err != nil {
194+
t.Fatalf("AnalyzeLibrary(pom.xml, online) unexpected error: %v", err)
195+
}
196+
197+
gotJSON, err := json.MarshalIndent(normalizeResult(got), "", " ")
198+
if err != nil {
199+
t.Fatalf("Failed to marshal result: %v", err)
200+
}
201+
202+
goldenPath := filepath.Join(goldenDir, "pom.xml.online.json")
203+
204+
if *update {
205+
if err := os.MkdirAll(goldenDir, 0755); err != nil {
206+
t.Fatalf("Failed to create golden dir: %v", err)
207+
}
208+
if err := os.WriteFile(goldenPath, gotJSON, 0644); err != nil {
209+
t.Fatalf("Failed to write golden file: %v", err)
210+
}
211+
t.Logf("Updated golden file: %s", goldenPath)
212+
return
213+
}
214+
215+
wantJSON, err := os.ReadFile(goldenPath)
216+
if err != nil {
217+
t.Fatalf("Golden file not found: %s (run with -update to generate)", goldenPath)
218+
}
219+
220+
if string(gotJSON) != string(wantJSON) {
221+
t.Errorf("AnalyzeLibrary(pom.xml, online) output differs from golden file.\nGot:\n%s\nWant:\n%s",
222+
string(gotJSON), string(wantJSON))
223+
}
224+
225+
// Online mode should resolve transitive dependencies, producing more results than offline.
226+
offlineGoldenPath := filepath.Join(goldenDir, "pom.xml.json")
227+
offlineJSON, err := os.ReadFile(offlineGoldenPath)
228+
if err != nil {
229+
t.Logf("Offline golden file not found, skipping comparison: %s", offlineGoldenPath)
230+
return
231+
}
232+
233+
var onlineRes []goldenLibraryScanner
234+
if err := json.Unmarshal(gotJSON, &onlineRes); err != nil {
235+
t.Fatalf("Failed to unmarshal online JSON result: %v", err)
236+
}
237+
var offlineRes []goldenLibraryScanner
238+
if err := json.Unmarshal(offlineJSON, &offlineRes); err != nil {
239+
t.Fatalf("Failed to unmarshal offline golden JSON: %v", err)
240+
}
241+
var onlineLibs, offlineLibs int
242+
for _, s := range onlineRes {
243+
onlineLibs += len(s.Libs)
244+
}
245+
for _, s := range offlineRes {
246+
offlineLibs += len(s.Libs)
247+
}
248+
if onlineLibs <= offlineLibs {
249+
t.Errorf("Online mode should resolve more dependencies than offline mode.\nOnline libs: %d\nOffline libs: %d",
250+
onlineLibs, offlineLibs)
251+
}
252+
}
253+
254+
// normalizeResult produces a stable, comparable representation of the scan result.
255+
// It sorts libraries by name+version to avoid ordering-dependent diffs.
256+
type goldenLibraryScanner struct {
257+
Type string `json:"type"`
258+
LockfilePath string `json:"lockfilePath"`
259+
Libs []goldenLibrary `json:"libs"`
260+
}
261+
262+
type goldenLibrary struct {
263+
Name string `json:"name"`
264+
Version string `json:"version"`
265+
PURL string `json:"purl,omitempty"`
266+
FilePath string `json:"filePath,omitempty"`
267+
Digest string `json:"digest,omitempty"`
268+
Dev bool `json:"dev,omitempty"`
269+
}
270+
271+
func normalizeResult(scanners []models.LibraryScanner) []goldenLibraryScanner {
272+
result := make([]goldenLibraryScanner, 0, len(scanners))
273+
for _, s := range scanners {
274+
gs := goldenLibraryScanner{
275+
Type: string(s.Type),
276+
LockfilePath: s.LockfilePath,
277+
Libs: make([]goldenLibrary, 0, len(s.Libs)),
278+
}
279+
for _, lib := range s.Libs {
280+
gs.Libs = append(gs.Libs, goldenLibrary{
281+
Name: lib.Name,
282+
Version: lib.Version,
283+
PURL: lib.PURL,
284+
FilePath: lib.FilePath,
285+
Digest: lib.Digest,
286+
Dev: lib.Dev,
287+
})
288+
}
289+
slices.SortFunc(gs.Libs, func(a, b goldenLibrary) int {
290+
return cmp.Or(
291+
cmp.Compare(a.Name, b.Name),
292+
cmp.Compare(a.Version, b.Version),
293+
cmp.Compare(a.PURL, b.PURL),
294+
cmp.Compare(a.FilePath, b.FilePath),
295+
cmp.Compare(a.Digest, b.Digest),
296+
func() int {
297+
switch {
298+
case !a.Dev && b.Dev:
299+
return -1
300+
case a.Dev && !b.Dev:
301+
return +1
302+
default:
303+
return 0
304+
}
305+
}(),
306+
)
307+
})
308+
result = append(result, gs)
309+
}
310+
slices.SortFunc(result, func(a, b goldenLibraryScanner) int {
311+
return cmp.Or(
312+
cmp.Compare(a.Type, b.Type),
313+
cmp.Compare(a.LockfilePath, b.LockfilePath),
314+
)
315+
})
316+
return result
317+
}

0 commit comments

Comments
 (0)