Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ builds:
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
Expand All @@ -28,6 +29,10 @@ archives:
- id: bumblebee
formats:
- tar.gz
format_overrides:
- goos: windows
formats:
- zip
name_template: >-
bumblebee_{{ .Version }}_{{ .Os }}_{{ .Arch }}
files:
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
69 changes: 40 additions & 29 deletions cmd/bumblebee/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>", "/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) {
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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"),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
96 changes: 96 additions & 0 deletions cmd/bumblebee/roots.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") ||
Expand Down Expand Up @@ -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:
// `<drive>:\`, `<drive>:\Users`, and `<drive>:\Users\<single-name>`.
// 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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 `<home>\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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
Loading