diff --git a/cmd/bumblebee/main_test.go b/cmd/bumblebee/main_test.go index 30158a6..e039892 100644 --- a/cmd/bumblebee/main_test.go +++ b/cmd/bumblebee/main_test.go @@ -158,6 +158,26 @@ func TestResolveRootsBaselineIncludesUserLocalPython(t *testing.T) { } } +func TestResolveRootsBaselineIncludesUVToolEnvironment(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + uvRoot := filepath.Join(home, ".cache", "uv", "environments-v2") + if err := os.MkdirAll(uvRoot, 0o755); err != nil { + t.Fatal(err) + } + + roots, _, err := resolveRoots(model.ProfileBaseline, nil, rootsOpts{}) + if err != nil { + t.Fatalf("resolveRoots baseline: %v", err) + } + for _, r := range roots { + if r.Path == uvRoot && r.Kind == model.RootKindUserPackage { + return + } + } + t.Fatalf("baseline profile did not include uv tool environment %q, got %v", uvRoot, 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. @@ -377,6 +397,75 @@ func TestResolveRootsDeepAllowsBroadHome(t *testing.T) { } } +func TestResolveRootsDeepIncludesNestedUVToolEnvironment(t *testing.T) { + users := t.TempDir() + t.Setenv("BUMBLEBEE_USERS_DIR", users) + uvRoot := filepath.Join(users, "tester", ".cache", "uv", "environments-v2") + if err := os.MkdirAll(uvRoot, 0o755); err != nil { + t.Fatal(err) + } + + roots, _, err := resolveRoots(model.ProfileDeep, []string{users}, rootsOpts{}) + if err != nil { + t.Fatalf("resolveRoots deep: %v", err) + } + for i, r := range roots { + if r.Path == uvRoot && r.Kind == model.RootKindUserPackage { + if i >= len(roots)-1 || roots[i+1].Path != users { + t.Fatalf("uv tool environment must be scanned before broad root %q, got %v", users, roots) + } + return + } + } + t.Fatalf("deep profile did not include uv tool environment %q, got %v", uvRoot, roots) +} + +func TestResolveRootsDeepDoesNotCrossSymlinkForUVToolEnvironment(t *testing.T) { + users := t.TempDir() + t.Setenv("BUMBLEBEE_USERS_DIR", users) + home := filepath.Join(users, "tester") + cache := filepath.Join(home, ".cache") + if err := os.MkdirAll(cache, 0o755); err != nil { + t.Fatal(err) + } + outsideUV := t.TempDir() + if err := os.MkdirAll(filepath.Join(outsideUV, "environments-v2"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outsideUV, filepath.Join(cache, "uv")); err != nil { + t.Fatal(err) + } + + roots, _, err := resolveRoots(model.ProfileDeep, []string{users}, rootsOpts{}) + if err != nil { + t.Fatalf("resolveRoots deep: %v", err) + } + for _, r := range roots { + if strings.Contains(r.Path, filepath.Join(".cache", "uv", "environments-v2")) { + t.Fatalf("deep profile crossed a symlink to add uv tool environment %q", r.Path) + } + } +} + +func TestResolveRootsDeepSkipsNonVersionedUVEnvironmentName(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + invalid := filepath.Join(home, ".cache", "uv", "environments-v-backup") + if err := os.MkdirAll(invalid, 0o755); err != nil { + t.Fatal(err) + } + + roots, _, err := resolveRoots(model.ProfileDeep, []string{home}, rootsOpts{}) + if err != nil { + t.Fatalf("resolveRoots deep: %v", err) + } + for _, r := range roots { + if r.Path == invalid { + t.Fatalf("deep profile included non-versioned uv directory %q", r.Path) + } + } +} + func TestResolveRootsDeepRequiresExplicitRoot(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) @@ -424,6 +513,13 @@ func TestClassifyRootHomebrewCellarAndCaskroom(t *testing.T) { } } +func TestClassifyRootUVToolEnvironment(t *testing.T) { + path := "/Users/alice/.cache/uv/environments-v2" + if got := classifyRoot(path, model.ProfileDeep); got != model.RootKindUserPackage { + t.Errorf("classifyRoot(%q) = %q, want %q", path, got, model.RootKindUserPackage) + } +} + func TestIsLikelyUserHomeName(t *testing.T) { keep := []string{"alice", "bob", "Alice", "user1", "first.last"} drop := []string{"", ".", "..", ".DS_Store", ".localized", "Shared", "shared", "Guest", "guest", "root", "Deleted Users"} @@ -736,6 +832,49 @@ func TestRunScanFindingsOnlyRequiresExposureCatalog(t *testing.T) { } } +func TestRunScanDeepFindsPackageInUVToolEnvironment(t *testing.T) { + users := t.TempDir() + t.Setenv("BUMBLEBEE_USERS_DIR", users) + metadata := filepath.Join(users, "tester", ".cache", "uv", "environments-v2", "cache-case", "lib", "python3.13", "site-packages", "cache-case-1.2.3.dist-info", "METADATA") + otherCacheMetadata := filepath.Join(users, "tester", ".cache", "other", "cache-case", "lib", "python3.13", "site-packages", "cache-case-1.2.3.dist-info", "METADATA") + for _, path := range []string{metadata, otherCacheMetadata} { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("Metadata-Version: 2.1\nName: cache-case\nVersion: 1.2.3\n\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + artifacts := t.TempDir() + catalog := filepath.Join(artifacts, "catalog.json") + if err := os.WriteFile(catalog, []byte(`{"schema_version":"0.1.0","entries":[{"id":"uv-cache-test","ecosystem":"pypi","package":"cache-case","versions":["1.2.3"]}]}`), 0o644); err != nil { + t.Fatal(err) + } + output := filepath.Join(artifacts, "scan.ndjson") + code := runScan([]string{ + "--profile", "deep", + "--root", users, + "--ecosystem", "pypi", + "--exposure-catalog", catalog, + "--findings-only", + "--output", "file", + "--output-file", output, + "--max-duration", "5s", + "--concurrency", "1", + }) + if code != 0 { + t.Fatalf("runScan exit code = %d, want 0", code) + } + body, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + if strings.Count(string(body), `"record_type":"finding"`) != 1 || !strings.Contains(string(body), `"catalog_id":"uv-cache-test"`) { + t.Fatalf("scan did not emit expected finding: %s", body) + } +} + func TestRunScanRejectsInvalidEcosystem(t *testing.T) { code := runScan([]string{"--profile", "baseline", "--ecosystem", "unknown"}) if code != 2 { diff --git a/cmd/bumblebee/roots.go b/cmd/bumblebee/roots.go index 09695dd..7f8d938 100644 --- a/cmd/bumblebee/roots.go +++ b/cmd/bumblebee/roots.go @@ -93,6 +93,9 @@ func resolveRoots(profile string, explicit []string, opts rootsOpts) (roots []sc } roots = append(roots, scanner.Root{Path: p, Kind: kind}) } + if profile == model.ProfileDeep { + roots = append(deepNestedPackageRoots(explicit), roots...) + } return roots, notes, nil } @@ -124,6 +127,8 @@ func resolveRoots(profile string, explicit []string, opts rootsOpts) (roots []sc func classifyRoot(path, profile string) string { p := filepath.ToSlash(filepath.Clean(path)) switch { + case isUVToolEnvironmentPath(p): + return model.RootKindUserPackage case strings.HasSuffix(p, "/extensions") && containsAny(p, ".vscode", ".cursor", ".windsurf", ".vscodium"): return model.RootKindEditorExtension case (strings.HasSuffix(p, "/Extensions") || strings.HasSuffix(p, "/extensions")) && @@ -170,6 +175,22 @@ func containsAny(s string, subs ...string) bool { return false } +func isUVToolEnvironmentPath(path string) bool { + parts := strings.Split(filepath.ToSlash(filepath.Clean(path)), "/") + for i := 0; i+2 < len(parts); i++ { + if parts[i] == ".cache" && parts[i+1] == "uv" && isUVToolEnvironmentName(parts[i+2]) { + return true + } + } + return false +} + +func isUVToolEnvironmentName(name string) bool { + const prefix = "environments-v" + version := strings.TrimPrefix(name, prefix) + return version != name && version != "" && strings.Trim(version, "0123456789") == "" +} + // isBroadHomeRoot reports whether path resolves to a bare user home or // filesystem root (e.g. $HOME, /Users/, /home/, /, or the // shared /Users or /home parents). baseline and project refuse such @@ -227,6 +248,9 @@ func baselineHomeCandidates(home string) []scanner.Root { add(p, model.RootKindUserPackage) } add(filepath.Join(home, ".local", "share", "pipx", "venvs"), model.RootKindUserPackage) + for _, r := range uvToolEnvironmentRoots(home) { + add(r.Path, r.Kind) + } // Editor extension trees. for _, seg := range []string{ @@ -286,6 +310,115 @@ func baselineHomeCandidates(home string) []scanner.Root { return out } +// deepNestedPackageRoots returns installed package trees inside directories +// that DefaultExcludes intentionally skips. These roots are walked before a +// broad deep root so an exposure scan can inspect them without opening the +// rest of the excluded directory. +func deepNestedPackageRoots(explicit []string) []scanner.Root { + var out []scanner.Root + seen := make(map[string]struct{}) + for _, root := range explicit { + for _, home := range homesCoveredByDeepRoot(root) { + for _, candidate := range uvToolEnvironmentRoots(home) { + if !plainDirectoryTree(root, candidate.Path) { + continue + } + clean := filepath.Clean(candidate.Path) + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + out = append(out, candidate) + } + } + } + return out +} + +func homesCoveredByDeepRoot(root string) []string { + abs, err := filepath.Abs(root) + if err != nil { + abs = root + } + abs = filepath.Clean(abs) + + switch abs { + case filepath.Clean(usersDirEffective()): + return allUsersHomes(abs) + case "/home": + return allUsersHomes(abs) + case "/": + homes := allUsersHomes(usersDirEffective()) + homes = append(homes, allUsersHomes("/home")...) + if info, err := os.Stat("/root"); err == nil && info.IsDir() { + homes = append(homes, "/root") + } + return homes + } + if isBroadHomeRoot(abs) { + return []string{abs} + } + return nil +} + +// uvToolEnvironmentRoots returns uvx/uv tool run virtual environments. uv +// stores these executable environments below the otherwise disposable uv +// cache; wheel, source, and build caches remain excluded. +func uvToolEnvironmentRoots(home string) []scanner.Root { + parent := filepath.Join(home, ".cache", "uv") + if !plainDirectoryTree(home, parent) { + return nil + } + entries, err := os.ReadDir(parent) + if err != nil { + return nil + } + var out []scanner.Root + for _, entry := range entries { + if !isUVToolEnvironmentName(entry.Name()) { + continue + } + path := filepath.Join(parent, entry.Name()) + if !plainDirectoryTree(home, path) { + continue + } + out = append(out, scanner.Root{ + Path: path, + Kind: model.RootKindUserPackage, + }) + } + return out +} + +func plainDirectoryTree(root, target string) bool { + absRoot, err := filepath.Abs(root) + if err != nil { + return false + } + absTarget, err := filepath.Abs(target) + if err != nil { + return false + } + rel, err := filepath.Rel(absRoot, absTarget) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return false + } + + path := absRoot + parts := []string{} + if rel != "." { + parts = strings.Split(rel, string(filepath.Separator)) + } + for _, part := range append([]string{""}, parts...) { + path = filepath.Join(path, part) + info, err := os.Lstat(path) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return false + } + } + return true +} + // projectHomeCandidates returns the per-home set of curated project // root candidates for one home directory. func projectHomeCandidates(home string) []scanner.Root { diff --git a/docs/inventory-sources.md b/docs/inventory-sources.md index 1e64555..42f12f8 100644 --- a/docs/inventory-sources.md +++ b/docs/inventory-sources.md @@ -193,6 +193,10 @@ Files read: We read only the RFC-822 header block of METADATA / PKG-INFO and stop at the first blank line, so the description payload is never scanned. +Baseline scans and broad deep roots add `~/.cache/uv/environments-v*` as a +targeted root. These are executable environments reused by `uvx` and +`uv tool run`. Other uv wheel, source, and build caches remain excluded. + References: - PEP 566 (METADATA):