From 722f963e2daa0f77854db7c89117d3347aeefce1 Mon Sep 17 00:00:00 2001 From: austinconnor Date: Fri, 22 May 2026 14:09:20 -0400 Subject: [PATCH] feat(windows): add default root support --- README.md | 12 +- cmd/bumblebee/main.go | 2 +- cmd/bumblebee/main_test.go | 44 +++++- cmd/bumblebee/roots.go | 198 +++++++++++++++++++++++++-- docs/deployment-windows.md | 67 +++++++++ docs/inventory-sources.md | 20 +-- internal/ecosystem/npm/npm.go | 2 +- internal/ecosystem/npm/npm_test.go | 3 + internal/ecosystem/pnpm/pnpm.go | 2 +- internal/ecosystem/pnpm/pnpm_test.go | 4 +- internal/scanner/scanner_test.go | 5 +- 11 files changed, 332 insertions(+), 27 deletions(-) create mode 100644 docs/deployment-windows.md diff --git a/README.md b/README.md index a94e626..3b2a904 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # bumblebee Bumblebee is a read-only inventory collector for package, extension, -and developer-tool metadata on macOS and Linux developer endpoints. +and developer-tool metadata on macOS, Linux, and Windows developer +endpoints. It answers a narrow supply-chain response question: when an advisory names a package, extension, or version, which developer machines show @@ -48,6 +49,8 @@ know what they are looking for. | Browser extensions | `browser-extension` | Chromium-family (`manifest.json`) and Firefox (`extensions.json`) per profile | Per-ecosystem detail: [docs/inventory-sources.md](docs/inventory-sources.md). +Deployment notes: [macOS](docs/deployment-macos.md) and +[Windows](docs/deployment-windows.md). ## Install @@ -68,6 +71,13 @@ go build -o bumblebee ./cmd/bumblebee go test ./... ``` +On Windows, build the `.exe` form: + +```powershell +go build -o bumblebee.exe ./cmd/bumblebee +go test ./... +``` + Stamp an explicit version at build time: ```sh diff --git a/cmd/bumblebee/main.go b/cmd/bumblebee/main.go index a65845c..2c0c162 100644 --- a/cmd/bumblebee/main.go +++ b/cmd/bumblebee/main.go @@ -144,7 +144,7 @@ func registerScanFlags(fs *flag.FlagSet, o *scanOpts) { "require --exposure-catalog and suppress only record_type=package output while still emitting findings, scan_summary, and diagnostics") fs.BoolVar(&o.allUsers, "all-users", false, - "on macOS, expand baseline/project per-user default roots across every real /Users// home. Useful for root-owned LaunchDaemon runs. Cannot be combined with --root or --profile=deep. System/Homebrew roots are still included once. No effect on Linux.") + "on macOS, expand baseline/project per-user default roots across every real /Users// home. Useful for root-owned LaunchDaemon runs. Cannot be combined with --root or --profile=deep. System/Homebrew roots are still included once. No effect on Linux or Windows.") fs.StringVar(&o.outputDest, "output", "stdout", "where to send records: stdout, file, or http") fs.StringVar(&o.outputFile, "output-file", "", "path for --output=file (NDJSON; required when --output=file)") diff --git a/cmd/bumblebee/main_test.go b/cmd/bumblebee/main_test.go index 7e9c1d1..716bd7d 100644 --- a/cmd/bumblebee/main_test.go +++ b/cmd/bumblebee/main_test.go @@ -90,8 +90,8 @@ func TestIsBroadHomeRoot(t *testing.T) { // profile's curated defaults do not include developer/project trees — // those belong to the project profile. func TestResolveRootsBaselineExcludesProjectTrees(t *testing.T) { - if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { - t.Skipf("profile defaults are darwin/linux specific") + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" && runtime.GOOS != "windows" { + t.Skipf("profile defaults are darwin/linux/windows specific") } home := t.TempDir() t.Setenv("HOME", home) @@ -158,6 +158,46 @@ func TestResolveRootsBaselineIncludesUserLocalPython(t *testing.T) { } } +func TestResolveRootsWindowsBaselineIncludesNativeRoots(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("windows root layout asserted here") + } + home := t.TempDir() + roaming := filepath.Join(home, "AppData", "Roaming") + local := filepath.Join(home, "AppData", "Local") + t.Setenv("HOME", home) + t.Setenv("APPDATA", roaming) + t.Setenv("LOCALAPPDATA", local) + + want := map[string]string{ + filepath.Join(roaming, "npm", "node_modules"): model.RootKindUserPackage, + filepath.Join(local, "Programs", "Python", "Python312"): model.RootKindUserPackage, + filepath.Join(roaming, "Claude"): model.RootKindMCPConfig, + filepath.Join(local, "Google", "Chrome", "User Data", "Default", "Extensions"): model.RootKindBrowserExtension, + filepath.Join(roaming, "Mozilla", "Firefox", "Profiles"): model.RootKindBrowserExtension, + filepath.Join(home, ".vscode", "extensions"): model.RootKindEditorExtension, + } + for p := range want { + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatal(err) + } + } + + roots, _, err := resolveRoots(model.ProfileBaseline, nil, rootsOpts{}) + if err != nil { + t.Fatalf("resolveRoots baseline: %v", err) + } + got := map[string]string{} + for _, r := range roots { + got[r.Path] = r.Kind + } + for p, kind := range want { + if got[p] != kind { + t.Errorf("root %q kind = %q, want %q (roots=%v)", p, got[p], kind, roots) + } + } +} + // TestResolveRootsBaselineIncludesClaudeAndCodexMCPRoots verifies that the // cross-platform Claude/Codex/Gemini user-home dotfiles are included in // baseline MCP roots when present, and dropped when absent. diff --git a/cmd/bumblebee/roots.go b/cmd/bumblebee/roots.go index bde8205..ff66937 100644 --- a/cmd/bumblebee/roots.go +++ b/cmd/bumblebee/roots.go @@ -44,11 +44,41 @@ type rootsOpts struct { // AllUsers, when true on macOS, expands the baseline/project profile // defaults across every real user home under /Users instead of only // the current process owner's home. System/Homebrew roots are still - // included exactly once. Has no effect on Linux, where multi-user - // fleet runs are not a supported deployment shape. + // included exactly once. Has no effect on Linux or Windows, where + // multi-user fleet runs are not a supported deployment shape. AllUsers bool } +func currentHomeDir() string { + if home := strings.TrimSpace(os.Getenv("HOME")); home != "" { + return home + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + return home + } + return "" +} + +func roamingAppDataDir(home string) string { + if d := strings.TrimSpace(os.Getenv("APPDATA")); d != "" { + return d + } + if home == "" { + return "" + } + return filepath.Join(home, "AppData", "Roaming") +} + +func localAppDataDir(home string) string { + if d := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); d != "" { + return d + } + if home == "" { + return "" + } + return filepath.Join(home, "AppData", "Local") +} + // resolveRoots picks the scan roots for the given profile. When the caller // supplied explicit --root entries, those are honored (and tagged with a // best-guess kind for the profile); otherwise the profile's curated @@ -132,6 +162,7 @@ func classifyRoot(path, profile string) string { case strings.HasSuffix(p, "/Profiles") && containsAny(p, "Firefox", "LibreWolf", "Waterfox"): return model.RootKindBrowserExtension case strings.Contains(p, "Library/Application Support/Claude") || + strings.Contains(p, "/AppData/Roaming/Claude") || strings.HasSuffix(p, "/.cursor") || strings.HasSuffix(p, "/.codeium/windsurf") || strings.HasSuffix(p, "/.claude") || @@ -172,26 +203,93 @@ func isBroadHomeRoot(path string) bool { if path == "" { return false } + rawSlash := filepath.ToSlash(filepath.Clean(path)) + if isUnixBroadHomePath(rawSlash) { + return true + } abs, err := filepath.Abs(path) if err != nil { abs = path } abs = filepath.Clean(abs) - if abs == "/" { + if isFilesystemRoot(abs) { return true } - if home, err := os.UserHomeDir(); err == nil && home != "" { - if abs == filepath.Clean(home) { + if home := currentHomeDir(); home != "" { + if samePath(abs, cleanAbs(home)) { return true } } - switch abs { + if isUnixBroadHomePath(filepath.ToSlash(abs)) { + return true + } + if runtime.GOOS == "windows" && isWindowsBroadHomePath(abs) { + return true + } + return false +} + +func cleanAbs(path string) string { + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + return filepath.Clean(abs) +} + +func samePath(a, b string) bool { + a = filepath.Clean(a) + b = filepath.Clean(b) + if runtime.GOOS == "windows" { + return strings.EqualFold(a, b) + } + return a == b +} + +func isFilesystemRoot(path string) bool { + path = filepath.Clean(path) + if path == "/" { + return true + } + vol := filepath.VolumeName(path) + if vol == "" { + return false + } + rest := strings.TrimPrefix(path, vol) + return rest == "" || rest == string(filepath.Separator) +} + +func isUnixBroadHomePath(p string) bool { + p = strings.TrimRight(filepath.ToSlash(filepath.Clean(p)), "/") + if p == "" { + p = "/" + } + switch p { case "/Users", "/home", "/root": return true } - if dir, _ := filepath.Split(abs); dir == "/Users/" || dir == "/home/" { + if dir, _ := filepath.Split(p); dir == "/Users/" || dir == "/home/" { + return true + } + return false +} + +func isWindowsBroadHomePath(path string) bool { + p := strings.TrimRight(filepath.ToSlash(filepath.Clean(path)), "/") + if len(p) >= 2 && p[1] == ':' { + p = p[2:] + } + p = strings.ToLower(p) + switch p { + case "/users", "/documents and settings": return true } + for _, parent := range []string{"/users", "/documents and settings"} { + if strings.HasPrefix(p, parent+"/") { + rest := strings.TrimPrefix(p, parent+"/") + return rest != "" && !strings.Contains(rest, "/") + } + } return false } @@ -221,6 +319,29 @@ func baselineHomeCandidates(home string) []scanner.Root { add(p, model.RootKindUserPackage) } add(filepath.Join(home, ".local", "share", "pipx", "venvs"), model.RootKindUserPackage) + if runtime.GOOS == "windows" { + roaming := roamingAppDataDir(home) + local := localAppDataDir(home) + add(filepath.Join(home, ".pyenv", "pyenv-win", "versions"), model.RootKindUserPackage) + add(filepath.Join(home, ".local", "pipx", "venvs"), model.RootKindUserPackage) + add(filepath.Join(home, ".gem"), model.RootKindUserPackage) + if roaming != "" { + add(filepath.Join(roaming, "npm", "node_modules"), model.RootKindUserPackage) + add(filepath.Join(roaming, "nvm"), model.RootKindUserPackage) + add(filepath.Join(roaming, "fnm", "node-versions"), model.RootKindUserPackage) + for _, p := range globExisting(filepath.Join(roaming, "Python", "Python*")) { + add(p, model.RootKindUserPackage) + } + } + if local != "" { + add(filepath.Join(local, "pipx", "venvs"), model.RootKindUserPackage) + add(filepath.Join(local, "pnpm", "global"), model.RootKindUserPackage) + add(filepath.Join(local, "Yarn", "Data", "global", "node_modules"), model.RootKindUserPackage) + for _, p := range globExisting(filepath.Join(local, "Programs", "Python", "Python*")) { + add(p, model.RootKindUserPackage) + } + } + } // Editor extension trees. for _, seg := range []string{ @@ -254,6 +375,11 @@ func baselineHomeCandidates(home string) []scanner.Root { add(filepath.Join(home, ".config", "Claude"), model.RootKindMCPConfig) add(filepath.Join(home, ".config", "Claude Code"), model.RootKindMCPConfig) add(filepath.Join(home, ".continue"), model.RootKindMCPConfig) + case "windows": + if roaming := roamingAppDataDir(home); roaming != "" { + add(filepath.Join(roaming, "Claude"), model.RootKindMCPConfig) + } + add(filepath.Join(home, ".continue"), model.RootKindMCPConfig) } // Browser extension trees. We point directly at the per-profile @@ -302,6 +428,30 @@ func systemRoots() []scanner.Root { } } return roots + case "windows": + var roots []scanner.Root + add := func(p string) { + if p != "" { + roots = append(roots, scanner.Root{Path: p, Kind: model.RootKindGlobalPackage}) + } + } + for _, env := range []string{"ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"} { + base := strings.TrimSpace(os.Getenv(env)) + if base == "" { + continue + } + add(filepath.Join(base, "nodejs", "node_modules")) + for _, p := range globExisting(filepath.Join(base, "Python*")) { + add(p) + } + for _, p := range globExisting(filepath.Join(base, "Python", "Python*")) { + add(p) + } + } + for _, p := range globExisting(`C:\Python*`) { + add(p) + } + return roots } return nil } @@ -381,7 +531,7 @@ func homesForExpansion(opts rootsOpts) []string { // Fall back to the current home if /Users enumeration found // nothing usable — never silently degrade to no homes. } - if home, _ := os.UserHomeDir(); home != "" { + if home := currentHomeDir(); home != "" { return []string{home} } return nil @@ -525,6 +675,20 @@ func browserExtensionCandidateRoots(home string) []string { filepath.Join(home, ".var", "app", "com.microsoft.Edge", "config", "microsoft-edge"), } chromiumBases["vivaldi"] = []string{filepath.Join(cfg, "vivaldi")} + case "windows": + local := localAppDataDir(home) + if local != "" { + chromiumBases["chrome"] = []string{filepath.Join(local, "Google", "Chrome", "User Data")} + chromiumBases["chromium"] = []string{filepath.Join(local, "Chromium", "User Data")} + chromiumBases["brave"] = []string{filepath.Join(local, "BraveSoftware", "Brave-Browser", "User Data")} + chromiumBases["edge"] = []string{filepath.Join(local, "Microsoft", "Edge", "User Data")} + chromiumBases["vivaldi"] = []string{filepath.Join(local, "Vivaldi", "User Data")} + chromiumBases["arc"] = []string{filepath.Join(local, "Arc", "User Data")} + for _, p := range globExisting(filepath.Join(local, "Packages", "TheBrowserCompany.Arc_*", "LocalCache", "Local", "Arc", "User Data")) { + chromiumBases["arc"] = append(chromiumBases["arc"], p) + } + chromiumBases["comet"] = []string{filepath.Join(local, "Comet", "User Data")} + } } for _, bases := range chromiumBases { for _, b := range bases { @@ -555,6 +719,15 @@ func browserExtensionCandidateRoots(home string) []string { filepath.Join(home, ".var", "app", "io.gitlab.librewolf-community", ".librewolf"), filepath.Join(home, ".waterfox"), ) + case "windows": + roaming := roamingAppDataDir(home) + if roaming != "" { + roots = append(roots, + filepath.Join(roaming, "Mozilla", "Firefox", "Profiles"), + filepath.Join(roaming, "LibreWolf", "Profiles"), + filepath.Join(roaming, "Waterfox", "Profiles"), + ) + } } return roots } @@ -565,12 +738,21 @@ func browserExtensionCandidateRoots(home string) []string { func filterExistingRoots(candidates []scanner.Root) ([]scanner.Root, []string) { var present []scanner.Root skipped := 0 + seen := map[string]struct{}{} for _, c := range candidates { info, err := os.Stat(c.Path) if err != nil || !info.IsDir() { skipped++ continue } + key := filepath.Clean(c.Path) + if runtime.GOOS == "windows" { + key = strings.ToLower(key) + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} present = append(present, c) } if len(present) == 0 { diff --git a/docs/deployment-windows.md b/docs/deployment-windows.md new file mode 100644 index 0000000..bfcade6 --- /dev/null +++ b/docs/deployment-windows.md @@ -0,0 +1,67 @@ +# Deploying bumblebee on Windows + +`bumblebee` is a one-shot binary with no built-in scheduler. On Windows, +the typical pattern is to run it from Task Scheduler, an MDM/endpoint +management job, or an EDR live-response command. + +Cadence is the runner's choice. The profile determines what gets walked: + +- `baseline` - bounded global/user package-manager and toolchain roots, + editor extensions, browser extensions, and MCP config locations. +- `project` - configured developer/project roots such as `%USERPROFILE%\code`. +- `deep` - operator-supplied roots for incident response. Pair this with + `--exposure-catalog` and, when useful, `--findings-only`. + +## Baseline example + +Run from PowerShell as the target user: + +```powershell +.\bumblebee.exe scan ` + --profile baseline ` + --max-duration 5m ` + --output http ` + --http-url https://inventory.example.com/v1/ingest ` + --http-auth bearer ` + --http-token-env BUMBLEBEE_TOKEN ` + --device-id-env BUMBLEBEE_DEVICE_ID +``` + +Preview the default roots first: + +```powershell +.\bumblebee.exe roots --profile baseline +``` + +The Windows baseline resolves existing roots under the current user's home, +including common Python, npm, pnpm, Yarn, nvm/fnm, editor extension, Claude +Desktop, Gemini, Cursor/Windsurf, Chromium-family browser, and Firefox-family +browser locations. Absent candidate paths are skipped. + +## Project example + +```powershell +.\bumblebee.exe scan ` + --profile project ` + --root "$HOME\code" ` + --root "$HOME\src" +``` + +`project` and `baseline` refuse broad home or filesystem roots. Use `deep` +when an incident-response sweep really does need a bare home directory: + +```powershell +.\bumblebee.exe scan ` + --profile deep ` + --root "$HOME" ` + --exposure-catalog .\catalog.json ` + --findings-only ` + --max-duration 10m +``` + +## Multi-user hosts + +`--all-users` is macOS-only. On Windows, schedule one per-user run for each +developer account, or enumerate explicit roots for a service-account run. This +keeps `endpoint.username` aligned with the account whose package inventory is +being reported. diff --git a/docs/inventory-sources.md b/docs/inventory-sources.md index a2dfb81..e770402 100644 --- a/docs/inventory-sources.md +++ b/docs/inventory-sources.md @@ -29,7 +29,7 @@ Each scan profile reads from a different slice of the sources below: | Profile | Sources walked | |-------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `baseline` | Homebrew lib prefixes; `/Library/Python`; Linux system Python (`/usr/lib/python*`, plus `/usr/local/lib`); user Python (`~/.local/lib/python*`, `~/.local/share/pipx/venvs`, `pyenv`); language version managers (`asdf`, `nvm`, `rbenv`, `rvm`); `~/.cargo`; `~/go`; editor-extension trees; MCP config locations; per-profile browser-extension trees (Chromium-family + Firefox-family, including common snap/flatpak paths). No project trees. | +| `baseline` | Homebrew lib prefixes; `/Library/Python`; Linux system Python (`/usr/lib/python*`, plus `/usr/local/lib`); Windows global Python / Node roots under `%ProgramFiles%`; user Python (`~/.local/lib/python*`, `%APPDATA%\Python\Python*`, `%LOCALAPPDATA%\Programs\Python\Python*`, pipx, `pyenv` / `pyenv-win`); language version managers (`asdf`, `nvm`, `fnm`, `rbenv`, `rvm`); `~/.cargo`; `~/go`; editor-extension trees; MCP config locations; per-profile browser-extension trees (Chromium-family + Firefox-family, including common snap/flatpak paths on Linux and `%LOCALAPPDATA%` / `%APPDATA%` profile paths on Windows). No project trees. | | `project` | Configured developer/project roots (`~/code`, `~/src`, `~/Developer`, `~/Projects`, `~/workspace`, and any explicit `--root`). All ecosystem parsers below apply within those trees. | | `deep` | Operator-supplied roots, typically a bare home directory during a campaign. Same ecosystem parsers; recommended only in combination with `--exposure-catalog` to emit `record_type=finding` records. | @@ -390,11 +390,13 @@ embeds the per-browser profile path. Every record carries Profile coverage on `baseline`: the curated default roots include `Default` and `Profile 1`..`Profile 9` for each Chromium-family browser, -plus the Firefox-family profile parents. Profiles outside that range -must be passed via `--root`. The deliberately narrow root list keeps the -walker out of every other Chromium / Firefox subtree (cookies, Login -Data, IndexedDB, Local Storage, Cache), which are TCC-protected on -macOS and privacy-sensitive on every host. +plus the Firefox-family profile parents. On Windows, Chromium-family +roots are resolved under `%LOCALAPPDATA%` and Firefox-family roots under +`%APPDATA%`. Profiles outside the built-in range must be passed via +`--root`. The deliberately narrow root list keeps the walker out of +every other Chromium / Firefox subtree (cookies, Login Data, IndexedDB, +Local Storage, Cache), which are TCC-protected on macOS and +privacy-sensitive on every host. Interaction with `--profile deep`: deep accepts a bare home root and therefore overlaps the baseline browser-extension roots. The walker @@ -408,9 +410,9 @@ they fall inside the deep-scan walk. On macOS the curated excludes additionally drop the entire `Library/Application Support/` subtree from a deep walk, so deep on macOS picks up browser extensions only when the operator passes the per-profile `Extensions/` directory -as an explicit `--root` (the baseline curated entry). On Linux the -deep walk descends into `~/.config///` but, again, -only opens path-shape-matched manifests. +as an explicit `--root` (the baseline curated entry). On Linux and +Windows, the deep walk can descend into browser profile directories but, +again, only opens path-shape-matched manifests. Example jq filters: diff --git a/internal/ecosystem/npm/npm.go b/internal/ecosystem/npm/npm.go index 832bec8..d59e11a 100644 --- a/internal/ecosystem/npm/npm.go +++ b/internal/ecosystem/npm/npm.go @@ -111,7 +111,7 @@ func IsNodeModulesPackageJSON(path string) (bool, string) { default: return false, "" } - projectPath := strings.Join(parts[:nmIdx], "/") + projectPath := filepath.FromSlash(strings.Join(parts[:nmIdx], "/")) if projectPath == "" { // The lockfile lives at a relative path like "node_modules/foo/package.json" // (no parent segments). Reporting the absolute root "/" would be diff --git a/internal/ecosystem/npm/npm_test.go b/internal/ecosystem/npm/npm_test.go index 1de09e2..7ea7e31 100644 --- a/internal/ecosystem/npm/npm_test.go +++ b/internal/ecosystem/npm/npm_test.go @@ -145,6 +145,9 @@ func TestScanNodeModulesPackageJSONLifecycleScripts(t *testing.T) { if !ok || proj == "" { t.Fatalf("IsNodeModulesPackageJSON failed: %v %q", ok, proj) } + if filepath.Clean(proj) != filepath.Clean(dir) { + t.Fatalf("projectPath = %q, want %q", proj, dir) + } s, got, _ := newCollector() if err := s.ScanNodeModulesPackageJSON(pj, proj, model.Record{}); err != nil { t.Fatalf("scan: %v", err) diff --git a/internal/ecosystem/pnpm/pnpm.go b/internal/ecosystem/pnpm/pnpm.go index ed3cb82..5e62019 100644 --- a/internal/ecosystem/pnpm/pnpm.go +++ b/internal/ecosystem/pnpm/pnpm.go @@ -84,7 +84,7 @@ func IsPnpmStorePackageJSON(path string) (ok bool, projectPath, name, version st } // Cross-check name parity, but trust the on-disk directory name. _ = name2 - projectPath = strings.Join(parts[:pnpmIdx-1], "/") + projectPath = filepath.FromSlash(strings.Join(parts[:pnpmIdx-1], "/")) if projectPath == "" { // Relative-rooted layout (e.g. "node_modules/.pnpm/..."). Use "." as // the relative-root marker rather than the absolute "/". diff --git a/internal/ecosystem/pnpm/pnpm_test.go b/internal/ecosystem/pnpm/pnpm_test.go index fca6f41..0a0981e 100644 --- a/internal/ecosystem/pnpm/pnpm_test.go +++ b/internal/ecosystem/pnpm/pnpm_test.go @@ -60,11 +60,11 @@ func TestSplitPnpmStoreDir(t *testing.T) { func TestIsPnpmStorePackageJSON(t *testing.T) { ok, proj, name, ver := IsPnpmStorePackageJSON("/x/proj/node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/package.json") - if !ok || proj != "/x/proj" || name != "lodash" || ver != "4.17.21" { + if !ok || filepath.ToSlash(proj) != "/x/proj" || name != "lodash" || ver != "4.17.21" { t.Errorf("got ok=%v proj=%q name=%q ver=%q", ok, proj, name, ver) } ok, proj, name, ver = IsPnpmStorePackageJSON("/x/proj/node_modules/.pnpm/@tanstack+query-core@5.0.0/node_modules/@tanstack/query-core/package.json") - if !ok || name != "@tanstack/query-core" || ver != "5.0.0" || proj != "/x/proj" { + if !ok || name != "@tanstack/query-core" || ver != "5.0.0" || filepath.ToSlash(proj) != "/x/proj" { t.Errorf("scoped: got ok=%v proj=%q name=%q ver=%q", ok, proj, name, ver) } if ok, _, _, _ := IsPnpmStorePackageJSON("/x/proj/node_modules/lodash/package.json"); ok { diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index 49dfb9c..1b16022 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -103,10 +103,11 @@ func TestEndToEndScan(t *testing.T) { var lockFromProj, lockFromDup, nmRec, pyRec bool for _, r := range records { + sourceFile := filepath.ToSlash(r.SourceFile) switch { - case r.Ecosystem == "npm" && r.SourceType == "npm-lockfile" && strings.Contains(r.SourceFile, "/proj/"): + case r.Ecosystem == "npm" && r.SourceType == "npm-lockfile" && strings.Contains(sourceFile, "/proj/"): lockFromProj = true - case r.Ecosystem == "npm" && r.SourceType == "npm-lockfile" && strings.Contains(r.SourceFile, "/dup/"): + case r.Ecosystem == "npm" && r.SourceType == "npm-lockfile" && strings.Contains(sourceFile, "/dup/"): lockFromDup = true case r.Ecosystem == "npm" && r.SourceType == "npm-node_modules": nmRec = true