Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions cmd/bumblebee/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"}
Expand Down Expand Up @@ -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 {
Expand Down
133 changes: 133 additions & 0 deletions cmd/bumblebee/roots.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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")) &&
Expand Down Expand Up @@ -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/<name>, /home/<name>, /, or the
// shared /Users or /home parents). baseline and project refuse such
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions docs/inventory-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -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): <https://peps.python.org/pep-0566/>
Expand Down