diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..54bb717 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# Go tooling assumes LF line endings. +# +# On windows-latest, actions/checkout inherits git's default +# core.autocrlf=true and rewrites checked-out files to CRLF. gofmt treats +# a CRLF file as unformatted, so the repo-wide `gofmt -l .` step in CI +# reports every .go file in the tree — not just changed ones — and the +# job fails for reasons unrelated to any actual formatting problem. +# +# Pinning LF keeps the check meaningful on all three runners, and matches +# what gofmt itself writes when a contributor runs it on Windows. +*.go text eol=lf +go.mod text eol=lf +go.sum text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6acdeab..90e4c50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -47,6 +47,11 @@ jobs: run: go build -buildvcs=false ./cmd/bumblebee - name: bumblebee selftest + # windows-latest defaults to PowerShell, where invoking an + # extensionless build output does not work the way it does in a + # POSIX shell. Pin bash so this step behaves identically on all + # three runners. + shell: bash run: | go build -buildvcs=false -o ./bumblebee ./cmd/bumblebee ./bumblebee selftest diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6326c09..b8d1f89 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -20,6 +20,7 @@ builds: goos: - darwin - linux + - windows goarch: - amd64 - arm64 @@ -28,6 +29,10 @@ archives: - id: bumblebee formats: - tar.gz + format_overrides: + - goos: windows + formats: + - zip name_template: >- bumblebee_{{ .Version }}_{{ .Os }}_{{ .Arch }} files: diff --git a/README.md b/README.md index ea870c0..49feded 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 diff --git a/cmd/bumblebee/main_test.go b/cmd/bumblebee/main_test.go index 30158a6..310ab33 100644 --- a/cmd/bumblebee/main_test.go +++ b/cmd/bumblebee/main_test.go @@ -52,17 +52,22 @@ func TestResolveDeviceIDEmptyEnv(t *testing.T) { func TestIsBroadHomeRoot(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + setHomeDir(t, home) broad := []string{ home, - home + "/", - "/", + home + string(filepath.Separator), "/Users", "/Users/someone", - "/home", - "/home/someone", - "/root", + } + if runtime.GOOS != "windows" { + // "/", "/home/", "/root" are Unix filesystem roots / bare + // homes and have no equivalent on Windows (filepath.Abs on + // Windows resolves "/" to the current drive root, not the + // global filesystem root). + broad = append(broad, "/", "/home", "/home/someone", "/root") + } else { + broad = append(broad, `C:\`, `C:\Users`, `C:\Users\someone`) } for _, p := range broad { if !isBroadHomeRoot(p) { @@ -86,15 +91,27 @@ func TestIsBroadHomeRoot(t *testing.T) { } } +// setHomeDir overrides os.UserHomeDir() for the duration of the test. +// Go's user-home resolution reads HOME on Unix and USERPROFILE on +// Windows; setting both keeps tests portable without each call site +// branching on runtime.GOOS. +func setHomeDir(t *testing.T, home string) { + t.Helper() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + // On Windows the AppData subtrees are the real source of MCP and + // browser-extension roots; pin them under the fake home so tests + // stay hermetic regardless of the CI runner's real APPDATA. + t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) + t.Setenv("LOCALAPPDATA", filepath.Join(home, "AppData", "Local")) +} + // TestResolveRootsBaselineExcludesProjectTrees verifies the baseline // 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") - } home := t.TempDir() - t.Setenv("HOME", home) + setHomeDir(t, home) codeDir := filepath.Join(home, "code") if err := os.MkdirAll(codeDir, 0o755); err != nil { t.Fatal(err) @@ -116,7 +133,7 @@ func TestResolveRootsBaselineExcludesProjectTrees(t *testing.T) { func TestResolveRootsProjectIncludesCodeDir(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + setHomeDir(t, home) codeDir := filepath.Join(home, "code") if err := os.MkdirAll(codeDir, 0o755); err != nil { t.Fatal(err) @@ -138,7 +155,7 @@ func TestResolveRootsProjectIncludesCodeDir(t *testing.T) { func TestResolveRootsBaselineIncludesUserLocalPython(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + setHomeDir(t, home) pyRoot := filepath.Join(home, ".local", "lib", "python3.12") if err := os.MkdirAll(filepath.Join(pyRoot, "site-packages"), 0o755); err != nil { t.Fatal(err) @@ -162,11 +179,8 @@ func TestResolveRootsBaselineIncludesUserLocalPython(t *testing.T) { // cross-platform Claude/Codex/Gemini user-home dotfiles are included in // baseline MCP roots when present, and dropped when absent. func TestResolveRootsBaselineIncludesClaudeAndCodexMCPRoots(t *testing.T) { - if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { - t.Skipf("profile defaults are darwin/linux specific") - } home := t.TempDir() - t.Setenv("HOME", home) + setHomeDir(t, home) want := []string{ filepath.Join(home, ".claude"), filepath.Join(home, ".codex"), @@ -206,11 +220,8 @@ func TestResolveRootsBaselineIncludesClaudeAndCodexMCPRoots(t *testing.T) { // and short-circuiting on an empty-defaults error would let regressions // slip through silently. func TestResolveRootsBaselineSkipsAbsentClaudeCodexRoots(t *testing.T) { - if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { - t.Skipf("profile defaults are darwin/linux specific") - } home := t.TempDir() - t.Setenv("HOME", home) + setHomeDir(t, home) // Provide one unrelated default so the baseline run does not fail // with "no default roots". `~/go` is not one of the MCP candidates // under test, so its presence cannot mask the assertion below. @@ -343,7 +354,7 @@ func TestClassifyRootClaudeCodexMCP(t *testing.T) { func TestResolveRootsBaselineRefusesBroadHome(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + setHomeDir(t, home) _, _, err := resolveRoots(model.ProfileBaseline, []string{home}, rootsOpts{}) if err == nil { t.Fatalf("expected refusal for baseline+%q", home) @@ -355,7 +366,7 @@ func TestResolveRootsBaselineRefusesBroadHome(t *testing.T) { func TestResolveRootsProjectRefusesBroadHome(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + setHomeDir(t, home) _, _, err := resolveRoots(model.ProfileProject, []string{home}, rootsOpts{}) if err == nil { t.Fatalf("expected refusal for project+%q", home) @@ -364,7 +375,7 @@ func TestResolveRootsProjectRefusesBroadHome(t *testing.T) { func TestResolveRootsDeepAllowsBroadHome(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + setHomeDir(t, home) roots, _, err := resolveRoots(model.ProfileDeep, []string{home}, rootsOpts{}) if err != nil { t.Fatalf("deep should accept broad home root: %v", err) @@ -379,7 +390,7 @@ func TestResolveRootsDeepAllowsBroadHome(t *testing.T) { func TestResolveRootsDeepRequiresExplicitRoot(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + setHomeDir(t, home) _, _, err := resolveRoots(model.ProfileDeep, nil, rootsOpts{}) if err == nil { t.Fatalf("deep with no roots should error") @@ -508,7 +519,7 @@ func TestResolveRootsBaselineAllUsersExpansion(t *testing.T) { []string{"Shared", "Guest", "root"}) t.Setenv("BUMBLEBEE_USERS_DIR", usersDir) // Make sure UserHomeDir() still resolves to something deterministic. - t.Setenv("HOME", realHomes[0]) + setHomeDir(t, realHomes[0]) // Create a small set of known per-user dirs that should be picked up. mustMkdir := func(p string) { @@ -571,7 +582,7 @@ func TestResolveRootsBaselineAllUsersIncludesSystemRoots(t *testing.T) { } usersDir, realHomes := fakeUsersDir(t, []string{"alice", "bob"}, nil) t.Setenv("BUMBLEBEE_USERS_DIR", usersDir) - t.Setenv("HOME", realHomes[0]) + setHomeDir(t, realHomes[0]) roots, _, err := resolveRoots(model.ProfileBaseline, nil, rootsOpts{AllUsers: true}) if err != nil { @@ -618,7 +629,7 @@ func TestResolveRootsAllUsersUnsupportedPlatformsNote(t *testing.T) { t.Skip("--all-users expands on darwin") } home := t.TempDir() - t.Setenv("HOME", home) + setHomeDir(t, home) pyRoot := filepath.Join(home, ".local", "lib", "python3.12") if err := os.MkdirAll(pyRoot, 0o755); err != nil { t.Fatal(err) @@ -644,7 +655,7 @@ func TestResolveRootsProjectAllUsersExpansion(t *testing.T) { } usersDir, realHomes := fakeUsersDir(t, []string{"alice", "bob"}, nil) t.Setenv("BUMBLEBEE_USERS_DIR", usersDir) - t.Setenv("HOME", realHomes[0]) + setHomeDir(t, realHomes[0]) for _, h := range realHomes { if err := os.MkdirAll(filepath.Join(h, "code"), 0o755); err != nil { @@ -745,7 +756,7 @@ func TestRunScanRejectsInvalidEcosystem(t *testing.T) { func TestRunRootsRejectsUnknownProfile(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + setHomeDir(t, home) code := runRoots([]string{"--profile", "scheduled"}) if code != 2 { t.Fatalf("runRoots --profile=scheduled exit = %d, want 2 (unknown profile)", code) diff --git a/cmd/bumblebee/roots.go b/cmd/bumblebee/roots.go index 09695dd..b27aa49 100644 --- a/cmd/bumblebee/roots.go +++ b/cmd/bumblebee/roots.go @@ -132,6 +132,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") || @@ -198,6 +199,32 @@ func isBroadHomeRoot(path string) bool { if dir, _ := filepath.Split(abs); dir == "/Users/" || dir == "/home/" { return true } + if runtime.GOOS == "windows" && isWindowsBroadHome(abs) { + return true + } + return false +} + +// isWindowsBroadHome recognises Windows bare-home and drive-root paths: +// `:\`, `:\Users`, and `:\Users\`. +// The drive letter is arbitrary; comparisons are case-insensitive +// because Windows filesystems are. +func isWindowsBroadHome(abs string) bool { + vol := filepath.VolumeName(abs) + if vol == "" { + return false + } + if abs == vol || abs == vol+`\` { + return true + } + rel := strings.Trim(filepath.ToSlash(strings.TrimPrefix(abs, vol)), "/") + if rel == "" { + return true + } + parts := strings.Split(rel, "/") + if len(parts) <= 2 && strings.EqualFold(parts[0], "Users") { + return true + } return false } @@ -264,6 +291,21 @@ 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": + // Claude Desktop's claude_desktop_config.json lives under + // %APPDATA%\Claude (Roaming). Other Windows MCP hosts (Cursor, + // Windsurf, VS Code) already land via the cross-platform + // ~/.cursor, ~/.codeium/windsurf, and editor-extension dotfile + // roots added above. + appdata := windowsRoamingAppData(home) + add(filepath.Join(appdata, "Claude"), model.RootKindMCPConfig) + add(filepath.Join(appdata, "Continue"), model.RootKindMCPConfig) + // Per-user Python. The python.org installer's default (non + // all-users) mode installs here, so this is where dist-info + // metadata lives on most Windows developer machines. + for _, p := range globExisting(filepath.Join(windowsLocalAppData(home), "Programs", "Python", "Python*", "Lib", "site-packages")) { + add(p, model.RootKindUserPackage) + } } // Agent-skill lock locations. ~/.agents holds the global @@ -330,10 +372,49 @@ func systemRoots() []scanner.Root { } } return roots + case "windows": + // Windows has no Homebrew/usr-lib analog, and per-user roots are + // added by baselineHomeCandidates. What does belong here is the + // machine-wide Python install: the python.org installer's + // all-users mode writes to %ProgramFiles%\PythonNN, whose + // Lib\site-packages holds dist-info metadata the PyPI scanner + // reads. + var roots []scanner.Root + for _, base := range []string{os.Getenv("ProgramFiles"), os.Getenv("ProgramFiles(x86)")} { + if strings.TrimSpace(base) == "" { + continue + } + for _, p := range globExisting(filepath.Join(base, "Python*", "Lib", "site-packages")) { + roots = append(roots, scanner.Root{Path: p, Kind: model.RootKindGlobalPackage}) + } + } + return roots } return nil } +// windowsRoamingAppData returns the absolute path to %APPDATA% (the +// per-user Roaming AppData directory). It prefers the env var so a +// machine-configured non-default location is honoured, and falls back +// to `\AppData\Roaming` which matches the default Windows layout. +// Callers should only invoke this on Windows. +func windowsRoamingAppData(home string) string { + if v := strings.TrimSpace(os.Getenv("APPDATA")); v != "" { + return v + } + return filepath.Join(home, "AppData", "Roaming") +} + +// windowsLocalAppData returns the absolute path to %LOCALAPPDATA% (the +// per-user Local AppData directory). Browser extension trees live here +// on Windows. Callers should only invoke this on Windows. +func windowsLocalAppData(home string) string { + if v := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); v != "" { + return v + } + return filepath.Join(home, "AppData", "Local") +} + func globExisting(pattern string) []string { matches, err := filepath.Glob(pattern) if err != nil { @@ -553,6 +634,14 @@ 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 := windowsLocalAppData(home) + 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 _, bases := range chromiumBases { for _, b := range bases { @@ -583,6 +672,13 @@ func browserExtensionCandidateRoots(home string) []string { filepath.Join(home, ".var", "app", "io.gitlab.librewolf-community", ".librewolf"), filepath.Join(home, ".waterfox"), ) + case "windows": + appdata := windowsRoamingAppData(home) + roots = append(roots, + filepath.Join(appdata, "Mozilla", "Firefox", "Profiles"), + filepath.Join(appdata, "LibreWolf", "Profiles"), + filepath.Join(appdata, "Waterfox", "Profiles"), + ) } return roots } diff --git a/internal/ecosystem/npm/npm.go b/internal/ecosystem/npm/npm.go index 832bec8..7273b8a 100644 --- a/internal/ecosystem/npm/npm.go +++ b/internal/ecosystem/npm/npm.go @@ -118,7 +118,11 @@ func IsNodeModulesPackageJSON(path string) (bool, string) { // misleading; "." is the sane relative-root marker. projectPath = "." } - return true, projectPath + // The path was slash-normalized above so the segment matching is + // separator-independent. Convert back so project_path is emitted as a + // native path — on Windows a record must carry C:\src\app, not + // C:/src/app, or receivers cannot join it against other host paths. + return true, filepath.FromSlash(projectPath) } // ScanLockfile parses an npm lockfile at path and emits records. diff --git a/internal/ecosystem/npm/npm_test.go b/internal/ecosystem/npm/npm_test.go index 1de09e2..26acf8d 100644 --- a/internal/ecosystem/npm/npm_test.go +++ b/internal/ecosystem/npm/npm_test.go @@ -224,3 +224,23 @@ func TestMalformedLockfile(t *testing.T) { t.Errorf("no records expected") } } + +// Regression for upstream issue #1: project_path must be emitted as a +// native path. The matcher slash-normalizes internally so its segment +// logic is separator-independent, but the value it returns must be +// converted back or Windows records carry C:/src/app instead of +// C:\src\app and receivers cannot join them against other host paths. +func TestNodeModulesProjectPathIsNative(t *testing.T) { + path := filepath.Join("srv", "app", "node_modules", "lodash", "package.json") + ok, projectPath := IsNodeModulesPackageJSON(path) + if !ok { + t.Fatalf("IsNodeModulesPackageJSON(%q) = false", path) + } + want := filepath.Join("srv", "app") + if projectPath != want { + t.Errorf("projectPath = %q, want native %q", projectPath, want) + } + if strings.Contains(projectPath, "/") && filepath.Separator != '/' { + t.Errorf("projectPath %q still contains forward slashes on this platform", projectPath) + } +} diff --git a/internal/ecosystem/pnpm/pnpm.go b/internal/ecosystem/pnpm/pnpm.go index ed3cb82..c27bc91 100644 --- a/internal/ecosystem/pnpm/pnpm.go +++ b/internal/ecosystem/pnpm/pnpm.go @@ -90,7 +90,10 @@ func IsPnpmStorePackageJSON(path string) (ok bool, projectPath, name, version st // the relative-root marker rather than the absolute "/". projectPath = "." } - return true, projectPath, name, version + // Slash normalization above only makes the segment matching + // separator-independent; project_path must be emitted natively so + // Windows records carry C:\src\app rather than C:/src/app. + return true, filepath.FromSlash(projectPath), name, version } // splitPnpmStoreDir splits a pnpm store-dir name into (name, version). diff --git a/internal/ecosystem/pnpm/pnpm_test.go b/internal/ecosystem/pnpm/pnpm_test.go index fca6f41..bb0a2b3 100644 --- a/internal/ecosystem/pnpm/pnpm_test.go +++ b/internal/ecosystem/pnpm/pnpm_test.go @@ -59,12 +59,18 @@ func TestSplitPnpmStoreDir(t *testing.T) { } func TestIsPnpmStorePackageJSON(t *testing.T) { + // The matcher slash-normalizes internally so its segment logic is + // separator-independent, but projectPath is returned as a native + // path. The expectation has to be converted too: on Windows this is + // `\x\proj`, not `/x/proj`. + wantProj := filepath.FromSlash("/x/proj") + 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 || proj != wantProj || 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" || proj != wantProj { 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 { @@ -295,3 +301,16 @@ packages: t.Errorf("expected DirectDependency nil, got %v", *out[0].DirectDependency) } } + +// Regression for upstream issue #1 — same native-path requirement as npm. +func TestPnpmStoreProjectPathIsNative(t *testing.T) { + path := filepath.Join("srv", "app", "node_modules", ".pnpm", "lodash@4.17.21", "node_modules", "lodash", "package.json") + ok, projectPath, _, _ := IsPnpmStorePackageJSON(path) + if !ok { + t.Fatalf("IsPnpmStorePackageJSON(%q) = false", path) + } + want := filepath.Join("srv", "app") + if projectPath != want { + t.Errorf("projectPath = %q, want native %q", projectPath, want) + } +} diff --git a/internal/endpoint/endpoint.go b/internal/endpoint/endpoint.go index eac4744..dc76930 100644 --- a/internal/endpoint/endpoint.go +++ b/internal/endpoint/endpoint.go @@ -27,7 +27,10 @@ func Current(deviceID string) model.Endpoint { if u, err := user.Current(); err == nil { ep.Username = u.Username ep.UID = u.Uid - } else { + } else if runtime.GOOS != "windows" { + // os.Getuid() returns -1 on Windows; leave UID empty there + // rather than emitting a misleading "-1" sentinel. On Unix + // the numeric uid is the right fallback. ep.UID = strconv.Itoa(os.Getuid()) } return ep diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index 31fb347..71f9a85 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -103,10 +103,13 @@ func TestEndToEndScan(t *testing.T) { var lockFromProj, lockFromDup, nmRec, pyRec bool for _, r := range records { + // source_file is a native path, so it is separator-dependent. + // Normalize before matching so these assertions hold on Windows. + 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 diff --git a/internal/walk/walk.go b/internal/walk/walk.go index c895f51..e413bdc 100644 --- a/internal/walk/walk.go +++ b/internal/walk/walk.go @@ -110,6 +110,21 @@ var DefaultExcludes = []string{ "Pictures/Photos Library.photoslibrary", "Pictures/Photo Booth Library", + // Windows AppData — OS-managed state, per-app caches, and installer + // scratch space. These are the Windows analogue of the macOS Library + // entries above: large, churn-heavy, and holding no inventory the + // scanner can use. The curated Windows roots in cmd/bumblebee point + // directly at the few AppData subpaths that do matter (Claude's MCP + // config, per-profile browser extension trees), so excluding the + // parents here does not cost coverage even when an operator passes + // --root "%USERPROFILE%" for a deep sweep. + "AppData/Local/Temp", + "AppData/Local/Microsoft", + "AppData/Local/Packages", + "AppData/LocalLow", + "AppData/Local/CrashDumps", + "AppData/Local/Google/Chrome/User Data/Default/Cache", + // Generic caches and high-cost build/dependency cache trees. ".cache", ".npm/_cacache",