diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3c509e3..2d9681d 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -17,10 +17,11 @@ type Config struct { HTMLOutputFile string // set by --html (not persisted) ColorMode string // "auto", "always", "never" Verbose bool // --verbose - EnableNPMScan *bool // nil=auto, true/false=explicit - EnableBrewScan *bool // nil=auto, true/false=explicit - EnablePythonScan *bool // nil=auto, true/false=explicit - SearchDirs []string // defaults to ["$HOME"] + EnableNPMScan *bool // nil=auto, true/false=explicit + EnableBrewScan *bool // nil=auto, true/false=explicit + EnablePythonScan *bool // nil=auto, true/false=explicit + IncludeBundledPlugins bool // --include-bundled-plugins: include bundled/platform plugins in output + SearchDirs []string // defaults to ["$HOME"] } // Parse parses CLI arguments and returns a Config. @@ -83,6 +84,8 @@ func Parse(args []string) (*Config, error) { case arg == "--disable-python-scan": v := false cfg.EnablePythonScan = &v + case arg == "--include-bundled-plugins": + cfg.IncludeBundledPlugins = true case strings.HasPrefix(arg, "--color="): mode := strings.TrimPrefix(arg, "--color=") if mode != "auto" && mode != "always" && mode != "never" { @@ -145,9 +148,10 @@ Options: --disable-npm-scan Disable Node.js package scanning --enable-brew-scan Enable Homebrew package scanning --disable-brew-scan Disable Homebrew package scanning - --enable-python-scan Enable Python package scanning - --disable-python-scan Disable Python package scanning - --verbose Show progress messages (suppressed by default) + --enable-python-scan Enable Python package scanning + --disable-python-scan Disable Python package scanning + --include-bundled-plugins Include bundled/platform plugins in output (Windows) + --verbose Show progress messages (suppressed by default) --color=WHEN Color mode: auto | always | never (default: auto) -v, --version Show version -h, --help Show this help diff --git a/internal/detector/eclipse_plugins.go b/internal/detector/eclipse_plugins.go index 3046787..4f2cd11 100644 --- a/internal/detector/eclipse_plugins.go +++ b/internal/detector/eclipse_plugins.go @@ -1,47 +1,536 @@ package detector import ( + "context" "path/filepath" "strings" + "time" "github.com/step-security/dev-machine-guard/internal/model" ) -// eclipseFeatureDirs are Eclipse feature directories to scan. -// Features represent installed plugins/extensions (both bundled and user-installed). -var eclipseFeatureDirs = []string{ +// eclipseBundledPrefixes are bundle ID prefixes that ship as part of the +// base Eclipse platform. Bundles matching these are tagged as "bundled". +// eclipseBundledPrefixes identifies bundles that ship as part of the Eclipse +// platform or are standard dependencies. Bundles that do not match these +// prefixes are treated as non-bundled and may be classified into source +// categories such as "marketplace", "user_installed", or "dropins". +var eclipseBundledPrefixes = []string{ + // Eclipse platform + "org.eclipse.", + "epp.", + "configure.", + // OSGi / Equinox runtime + "org.osgi.", + // Apache libraries + "org.apache.", + // JVM / standard APIs + "javax.", + "jakarta.", + "com.sun.", + "com.ibm.icu", + // Common platform dependencies + "org.objectweb.", + "org.sat4j.", + "org.tukaani.", + "org.w3c.", + "org.xml.sax", + "org.hamcrest", + "org.junit", + "org.opentest4j", + "org.apiguardian", + "org.commonmark", + "org.mortbay.", + "org.jdom", + "org.jsoup", + "org.snakeyaml", + "org.jcodings", + "org.joni", + "org.glassfish.", + "org.gradle.", + "org.jacoco.", + // JUnit platform (ships with Eclipse JDT) + "junit-jupiter", + "junit-platform", + "junit-vintage", + // Crypto / SSH / networking + "bcpg", + "bcpkix", + "bcprov", + "bcutil", + "com.jcraft.", + "net.i2p.crypto", + "net.bytebuddy", + // Google / JSON / utilities + "com.google.gson", + "com.google.guava", + "com.googlecode.", + // Logging + "ch.qos.logback", + "slf4j.", + // Build tooling + "args4j", + "biz.aQute.", + // Other standard Eclipse deps + "com.sun.xml.", + "jaxen", +} + +// eclipseIniPatterns are .ini filenames for Eclipse-family products. +var eclipseIniPatterns = []string{ + "eclipse.ini", + "sts.ini", + "SpringToolSuite.ini", + "myeclipse.ini", +} + +// ---------- macOS detection (unchanged) ---------- + +var eclipseFeatureDirsDarwin = []string{ "/Applications/Eclipse.app/Contents/Eclipse/features", "/Applications/Eclipse.app/Contents/Eclipse/dropins", } -// eclipseBundledPrefixes are feature ID prefixes that ship as part of the -// base Eclipse platform. Features matching these are tagged as "bundled". -var eclipseBundledPrefixes = []string{ - "org.eclipse.platform", - "org.eclipse.rcp", - "org.eclipse.e4.rcp", - "org.eclipse.equinox.", - "org.eclipse.help", - "org.eclipse.justj.", - "org.eclipse.oomph.", - "org.eclipse.epp.package.", -} - -// DetectEclipsePlugins scans Eclipse feature directories and returns -// all features tagged as "bundled" or "user_installed". -func (d *ExtensionDetector) DetectEclipsePlugins() []model.Extension { +// ---------- Public API ---------- + +// DetectEclipsePlugins scans Eclipse installations for plugins. +// On macOS: scans features/dropins directories. +// On Windows: multi-stage pipeline using detected IDE paths, path probes, +// and drive letter scanning, with validation before reporting. +func (d *ExtensionDetector) DetectEclipsePlugins(ctx context.Context, ides []model.IDE) []model.Extension { + if d.exec.GOOS() != "windows" { + var results []model.Extension + for _, dir := range eclipseFeatureDirsDarwin { + if d.exec.DirExists(dir) { + results = append(results, d.collectEclipseFeatures(dir)...) + } + } + return results + } + return d.detectEclipsePluginsWindows(ctx, ides) +} + +// ---------- Windows multi-stage pipeline ---------- + +func (d *ExtensionDetector) detectEclipsePluginsWindows(ctx context.Context, ides []model.IDE) []model.Extension { + // Stage 1+2: Collect candidate paths from detected IDEs + well-known locations + candidates := d.gatherEclipseCandidates(ides) + + // Stage 4: Validate each candidate + seen := make(map[string]bool) + var validInstalls []string + for _, path := range candidates { + key := strings.ToLower(filepath.Clean(path)) + if seen[key] { + continue + } + seen[key] = true + + if d.validateEclipseInstall(path) { + validInstalls = append(validInstalls, path) + } + } + + // Stage 6: Enumerate plugins from each validated install + pluginSeen := make(map[string]bool) + var results []model.Extension + for _, installDir := range validInstalls { + plugins := d.enumerateEclipsePlugins(ctx, installDir) + for _, p := range plugins { + dedupKey := p.ID + "@" + p.Version + if pluginSeen[dedupKey] { + continue + } + pluginSeen[dedupKey] = true + results = append(results, p) + } + } + + return results +} + +// gatherEclipseCandidates collects candidate install paths from multiple sources. +func (d *ExtensionDetector) gatherEclipseCandidates(ides []model.IDE) []string { + var candidates []string + + // Source 1: Detected IDEs (registry-aware — handles custom install paths) + for _, ide := range ides { + if ide.IDEType == "eclipse" && ide.InstallPath != "" { + candidates = append(candidates, ide.InstallPath) + } + } + + // Source 2: Well-known path probes + programFiles := d.exec.Getenv("PROGRAMFILES") + programFilesX86 := d.exec.Getenv("PROGRAMFILES(X86)") + userProfile := d.exec.Getenv("USERPROFILE") + localAppData := d.exec.Getenv("LOCALAPPDATA") + + // Machine-scope + if programFiles != "" { + candidates = append(candidates, filepath.Join(programFiles, "eclipse")) + } + if programFilesX86 != "" { + candidates = append(candidates, filepath.Join(programFilesX86, "eclipse")) + } + candidates = append(candidates, `C:\eclipse`) + + // STS / vendor variants + if programFiles != "" { + candidates = append(candidates, d.globDirs(filepath.Join(programFiles, "sts-*"))...) + } + + // User-scope: Oomph installer default + if userProfile != "" { + eclipseUserDir := filepath.Join(userProfile, "eclipse") + if d.exec.DirExists(eclipseUserDir) { + entries, err := d.exec.ReadDir(eclipseUserDir) + if err == nil { + for _, e := range entries { + if e.IsDir() { + candidates = append(candidates, filepath.Join(eclipseUserDir, e.Name(), "eclipse")) + } + } + } + } + } + + // User-scope: LOCALAPPDATA + if localAppData != "" { + candidates = append(candidates, d.globDirs(filepath.Join(localAppData, "Programs", "Eclipse*"))...) + candidates = append(candidates, d.globDirs(filepath.Join(localAppData, "Programs", "Spring*"))...) + } + + // Drive letter probe: D:\eclipse through Z:\eclipse. + // Only probe drives that actually exist to avoid slow network drive timeouts. + for drive := 'D'; drive <= 'Z'; drive++ { + driveRoot := string(drive) + `:\` + if !d.exec.DirExists(driveRoot) { + continue + } + candidates = append(candidates, string(drive)+`:\eclipse`) + } + + return candidates +} + +// globDirs expands a glob pattern and returns matching directories. +func (d *ExtensionDetector) globDirs(pattern string) []string { + matches, err := d.exec.Glob(pattern) + if err != nil || len(matches) == 0 { + return nil + } + var dirs []string + for _, m := range matches { + if d.exec.DirExists(m) { + dirs = append(dirs, m) + } + } + return dirs +} + +// validateEclipseInstall checks that a candidate directory is actually an Eclipse install. +// Requires: an .ini file + plugins/ directory + configuration/ directory. +func (d *ExtensionDetector) validateEclipseInstall(installDir string) bool { + if !d.exec.DirExists(installDir) { + return false + } + + // Check for eclipse.ini or branded variant + hasIni := false + for _, ini := range eclipseIniPatterns { + if d.exec.FileExists(filepath.Join(installDir, ini)) { + hasIni = true + break + } + } + if !hasIni { + return false + } + + // Check for plugins/ and configuration/ directories + if !d.exec.DirExists(filepath.Join(installDir, "plugins")) { + return false + } + if !d.exec.DirExists(filepath.Join(installDir, "configuration")) { + return false + } + + return true +} + +// enumerateEclipsePlugins collects plugins from a validated Eclipse install. +// Uses the p2 director API (eclipsec.exe -listInstalledRoots) to get authoritative +// installed features, then enriches with bundles.info for full bundle details. +func (d *ExtensionDetector) enumerateEclipsePlugins(ctx context.Context, installDir string) []model.Extension { + // Try p2 director first — returns authoritative list of installed root features + roots := d.queryP2InstalledRoots(ctx, installDir) + + // Build a set of root feature prefixes for marketplace classification. + // Root features that are NOT org.eclipse.* or epp.* are marketplace-installed. + marketplaceRoots := make(map[string]bool) + for _, root := range roots { + if !strings.HasPrefix(root.ID, "org.eclipse.") && !strings.HasPrefix(root.ID, "epp.") { + // Strip ".feature.group" suffix to get the base feature ID + baseID := strings.TrimSuffix(root.ID, ".feature.group") + baseID = strings.TrimSuffix(baseID, ".feature") + marketplaceRoots[baseID] = true + } + } + + var results []model.Extension + seen := make(map[string]bool) + + // If we got roots from p2 director, use them for classification + if len(roots) > 0 { + // Add root features as extensions + for _, root := range roots { + key := root.ID + "@" + root.Version + if seen[key] { + continue + } + seen[key] = true + results = append(results, root) + } + + // Also parse bundles.info for non-root bundles that belong to marketplace features. + // A bundle belongs to a marketplace feature if its ID starts with any marketplace root prefix. + bundlesInfo := filepath.Join(installDir, "configuration", + "org.eclipse.equinox.simpleconfigurator", "bundles.info") + for _, ext := range d.parseEclipseBundlesInfo(bundlesInfo) { + key := ext.ID + "@" + ext.Version + if seen[key] { + continue + } + // Check if this bundle belongs to a marketplace feature + for prefix := range marketplaceRoots { + if strings.HasPrefix(ext.ID, prefix) { + ext.Source = "marketplace" + seen[key] = true + results = append(results, ext) + break + } + } + } + } else { + // Fallback: bundles.info parsing (p2 director unavailable) + bundlesInfo := filepath.Join(installDir, "configuration", + "org.eclipse.equinox.simpleconfigurator", "bundles.info") + for _, ext := range d.parseEclipseBundlesInfo(bundlesInfo) { + key := ext.ID + "@" + ext.Version + if !seen[key] { + seen[key] = true + results = append(results, ext) + } + } + } + + // Also check dropins/ + dropinsDir := filepath.Join(installDir, "dropins") + for _, ext := range d.collectDropins(dropinsDir) { + key := ext.ID + "@" + ext.Version + if !seen[key] { + seen[key] = true + results = append(results, ext) + } + } + + return results +} + +// queryP2InstalledRoots invokes Eclipse's p2 director to get the authoritative +// list of installed root features. Returns nil if eclipsec.exe is not available +// or the command fails. Output format: "feature.id/version" per line. +func (d *ExtensionDetector) queryP2InstalledRoots(ctx context.Context, installDir string) []model.Extension { + // Find eclipsec.exe (console launcher) + eclipsec := filepath.Join(installDir, "eclipsec.exe") + if !d.exec.FileExists(eclipsec) { + // Try eclipse.exe as fallback (may open a window briefly) + eclipsec = filepath.Join(installDir, "eclipse.exe") + if !d.exec.FileExists(eclipsec) { + return nil + } + } + + stdout, _, exitCode, err := d.exec.RunWithTimeout(ctx, 30*time.Second, + eclipsec, "-nosplash", + "-application", "org.eclipse.equinox.p2.director", + "-listInstalledRoots") + if err != nil || exitCode != 0 { + return nil + } + + var results []model.Extension + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Skip debug/log lines — they contain spaces and timestamps. + // Valid p2 output lines are "feature.id/version" with no spaces before the "/" + if strings.Contains(line, " ") { + continue + } + if !strings.Contains(line, "/") { + continue + } + + parts := strings.SplitN(line, "/", 2) + if len(parts) != 2 { + continue + } + + featureID := parts[0] + version := parts[1] + if featureID == "" || version == "" { + continue + } + + source := "bundled" + if !strings.HasPrefix(featureID, "org.eclipse.") && !strings.HasPrefix(featureID, "epp.") { + source = "marketplace" + } + + results = append(results, model.Extension{ + ID: featureID, + Name: featureID, + Version: version, + Publisher: extractPublisher(featureID), + IDEType: "eclipse", + Source: source, + }) + } + + return results +} + +// parseEclipseBundlesInfo reads an Eclipse bundles.info file. +// Format: id,version,location,startLevel,autoStart (one per line, # comments) +func (d *ExtensionDetector) parseEclipseBundlesInfo(filePath string) []model.Extension { + data, err := d.exec.ReadFile(filePath) + if err != nil { + return nil + } + var results []model.Extension - for _, dir := range eclipseFeatureDirs { - if !d.exec.DirExists(dir) { + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, ",", 5) + if len(parts) < 2 { + continue + } + + pluginID := strings.TrimSpace(parts[0]) + version := strings.TrimSpace(parts[1]) + if pluginID == "" || version == "" { continue } - results = append(results, d.collectEclipseFeatures(dir)...) + + publisher := extractPublisher(pluginID) + source := "user_installed" + if isEclipseBundled(pluginID) { + source = "bundled" + } + + results = append(results, model.Extension{ + ID: pluginID, + Name: pluginID, + Version: version, + Publisher: publisher, + IDEType: "eclipse", + Source: source, + }) } + return results } -// collectEclipseFeatures reads Eclipse features from a directory. -// Each feature is tagged as "bundled" or "user_installed". +// collectDropins scans the dropins/ directory for additional plugins. +// Handles direct JARs, directory bundles, and nested eclipse/plugins layouts. +func (d *ExtensionDetector) collectDropins(dropinsDir string) []model.Extension { + if !d.exec.DirExists(dropinsDir) { + return nil + } + + entries, err := d.exec.ReadDir(dropinsDir) + if err != nil { + return nil + } + + var results []model.Extension + for _, entry := range entries { + name := entry.Name() + + // Direct JAR: dropins/com.example.plugin_1.0.0.jar + if !entry.IsDir() && strings.HasSuffix(name, ".jar") { + if ext := parseEclipsePluginName(strings.TrimSuffix(name, ".jar")); ext != nil { + ext.Source = "dropins" + results = append(results, *ext) + } + continue + } + + if !entry.IsDir() { + continue + } + + // Directory bundle: dropins/com.example.plugin_1.0.0/ + if ext := parseEclipsePluginName(name); ext != nil { + ext.Source = "dropins" + results = append(results, *ext) + continue + } + + // Nested layout: dropins//eclipse/plugins/ or dropins//plugins/ + subPath := filepath.Join(dropinsDir, name) + for _, nested := range []string{ + filepath.Join(subPath, "eclipse", "plugins"), + filepath.Join(subPath, "plugins"), + } { + if !d.exec.DirExists(nested) { + continue + } + nestedEntries, err := d.exec.ReadDir(nested) + if err != nil { + continue + } + for _, ne := range nestedEntries { + baseName := strings.TrimSuffix(ne.Name(), ".jar") + if ext := parseEclipsePluginName(baseName); ext != nil { + ext.Source = "dropins" + results = append(results, *ext) + } + } + } + } + + return results +} + +// ---------- Shared helpers ---------- + +func isEclipseBundled(pluginID string) bool { + for _, prefix := range eclipseBundledPrefixes { + if strings.HasPrefix(pluginID, prefix) { + return true + } + } + return false +} + +func extractPublisher(pluginID string) string { + parts := strings.SplitN(pluginID, ".", 3) + if len(parts) >= 2 { + return parts[0] + "." + parts[1] + } + return "unknown" +} + +// collectEclipseFeatures reads Eclipse features from a directory (macOS). func (d *ExtensionDetector) collectEclipseFeatures(featuresDir string) []model.Extension { entries, err := d.exec.ReadDir(featuresDir) if err != nil { @@ -58,7 +547,6 @@ func (d *ExtensionDetector) collectEclipseFeatures(featuresDir string) []model.E continue } - // Tag as bundled or user_installed if isEclipseBundled(ext.ID) { ext.Source = "bundled" } else { @@ -77,19 +565,8 @@ func (d *ExtensionDetector) collectEclipseFeatures(featuresDir string) []model.E return results } -func isEclipseBundled(pluginID string) bool { - for _, prefix := range eclipseBundledPrefixes { - if strings.HasPrefix(pluginID, prefix) { - return true - } - } - return false -} - // parseEclipsePluginName parses "id_version" format. // Example: "com.github.spotbugs.plugin.eclipse_4.9.8.r202510181643-c1fa7f2" -// -// → id=com.github.spotbugs.plugin.eclipse, version=4.9.8.r202510181643-c1fa7f2 func parseEclipsePluginName(name string) *model.Extension { lastUnderscore := -1 for i := len(name) - 1; i >= 0; i-- { @@ -112,17 +589,12 @@ func parseEclipsePluginName(name string) *model.Extension { return nil } - publisher := "unknown" - parts := strings.SplitN(pluginID, ".", 3) - if len(parts) >= 2 { - publisher = parts[0] + "." + parts[1] - } - return &model.Extension{ ID: pluginID, Name: pluginID, Version: version, - Publisher: publisher, + Publisher: extractPublisher(pluginID), IDEType: "eclipse", } } + diff --git a/internal/detector/eclipse_plugins_test.go b/internal/detector/eclipse_plugins_test.go new file mode 100644 index 0000000..32d760e --- /dev/null +++ b/internal/detector/eclipse_plugins_test.go @@ -0,0 +1,293 @@ +package detector + +import ( + "context" + "os" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +func TestValidateEclipseInstall(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + + installDir := `C:\eclipse` + mock.SetDir(installDir) + // filepath.Join on macOS uses "/" between parts but preserves existing "\" + mock.SetFile(installDir+"/eclipse.ini", []byte{}) + mock.SetDir(installDir + "/plugins") + mock.SetDir(installDir + "/configuration") + + det := &ExtensionDetector{exec: mock} + if !det.validateEclipseInstall(installDir) { + t.Error("expected valid Eclipse install") + } +} + +func TestValidateEclipseInstall_MissingIni(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + + installDir := `C:\eclipse` + mock.SetDir(installDir) + // No eclipse.ini + mock.SetDir(installDir + `\plugins`) + mock.SetDir(installDir + `\configuration`) + + det := &ExtensionDetector{exec: mock} + if det.validateEclipseInstall(installDir) { + t.Error("expected invalid — missing eclipse.ini") + } +} + +func TestValidateEclipseInstall_BrandedIni(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + + installDir := `C:\sts` + mock.SetDir(installDir) + mock.SetFile(installDir+"/sts.ini", []byte{}) // Spring Tool Suite + mock.SetDir(installDir + "/plugins") + mock.SetDir(installDir + "/configuration") + + det := &ExtensionDetector{exec: mock} + if !det.validateEclipseInstall(installDir) { + t.Error("expected valid — branded sts.ini should count") + } +} + +func TestParseEclipseBundlesInfo(t *testing.T) { + mock := executor.NewMock() + mock.SetFile("/test/bundles.info", []byte(`#encoding=UTF-8 +#version=1 +org.eclipse.platform,4.39.0,file:/plugins/org.eclipse.platform_4.39.0.jar,4,false +com.anthropic.claudecode.eclipse,2.3.11,file:/plugins/com.anthropic.claudecode.eclipse_2.3.11.jar,4,false +`)) + + det := &ExtensionDetector{exec: mock} + results := det.parseEclipseBundlesInfo("/test/bundles.info") + + if len(results) != 2 { + t.Fatalf("expected 2 bundles, got %d", len(results)) + } + + if results[0].ID != "org.eclipse.platform" { + t.Errorf("expected org.eclipse.platform, got %s", results[0].ID) + } + if results[0].Source != "bundled" { + t.Errorf("expected bundled, got %s", results[0].Source) + } + + if results[1].ID != "com.anthropic.claudecode.eclipse" { + t.Errorf("expected com.anthropic.claudecode.eclipse, got %s", results[1].ID) + } + if results[1].Source != "user_installed" { + t.Errorf("expected user_installed, got %s", results[1].Source) + } +} + +func TestParseEclipseBundlesInfo_MissingFile(t *testing.T) { + mock := executor.NewMock() + det := &ExtensionDetector{exec: mock} + results := det.parseEclipseBundlesInfo("/nonexistent") + if len(results) != 0 { + t.Errorf("expected 0, got %d", len(results)) + } +} + +func TestQueryP2InstalledRoots_ParsesOutput(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + // filepath.Join on macOS uses "/" between parts + eclipsec := `C:\eclipse` + "/eclipsec.exe" + mock.SetFile(eclipsec, []byte{}) + + mock.SetCommand( + "civerooni.com.putman.feature.feature.group/1.0.0\n"+ + "com.anthropic.claudecode.eclipse.feature.feature.group/2.3.11\n"+ + "org.eclipse.jdt.feature.group/3.20.0\n"+ + "epp.package.java/4.39.0\n"+ + "Operation completed in 2131 ms.\n", + "", 0, + eclipsec, "-nosplash", + "-application", "org.eclipse.equinox.p2.director", + "-listInstalledRoots", + ) + + det := &ExtensionDetector{exec: mock} + results := det.queryP2InstalledRoots(context.Background(), `C:\eclipse`) + + if len(results) != 4 { + t.Fatalf("expected 4 root features, got %d", len(results)) + } + + marketplace := 0 + bundled := 0 + for _, r := range results { + if r.Source == "marketplace" { + marketplace++ + } else if r.Source == "bundled" { + bundled++ + } + } + + if marketplace != 2 { + t.Errorf("expected 2 marketplace, got %d", marketplace) + } + if bundled != 2 { + t.Errorf("expected 2 bundled, got %d", bundled) + } +} + +func TestQueryP2InstalledRoots_SkipsDebugLines(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + eclipsec := `C:\eclipse` + "/eclipsec.exe" + mock.SetFile(eclipsec, []byte{}) + + mock.SetCommand( + "18:53:22.314 [Start Level] DEBUG org.eclipse.jgit -- some debug\n"+ + "org.eclipse.platform.feature.group/4.39.0\n"+ + "Operation completed in 100 ms.\n", + "", 0, + eclipsec, "-nosplash", + "-application", "org.eclipse.equinox.p2.director", + "-listInstalledRoots", + ) + + det := &ExtensionDetector{exec: mock} + results := det.queryP2InstalledRoots(context.Background(), `C:\eclipse`) + + if len(results) != 1 { + t.Fatalf("expected 1 (debug lines filtered), got %d", len(results)) + } + if results[0].ID != "org.eclipse.platform.feature.group" { + t.Errorf("expected org.eclipse.platform.feature.group, got %s", results[0].ID) + } +} + +func TestQueryP2InstalledRoots_NoEclipsec(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + // No eclipsec.exe or eclipse.exe + + det := &ExtensionDetector{exec: mock} + results := det.queryP2InstalledRoots(context.Background(), `C:\eclipse`) + + if len(results) != 0 { + t.Errorf("expected 0 when no exe found, got %d", len(results)) + } +} + +func TestCollectDropins_DirectJAR(t *testing.T) { + mock := executor.NewMock() + dropinsDir := "/eclipse/dropins" + mock.SetDir(dropinsDir) + mock.SetDirEntries(dropinsDir, []os.DirEntry{ + executor.MockDirEntry("com.example.plugin_1.0.0.jar", false), + }) + + det := &ExtensionDetector{exec: mock} + results := det.collectDropins(dropinsDir) + + if len(results) != 1 { + t.Fatalf("expected 1 dropin, got %d", len(results)) + } + if results[0].ID != "com.example.plugin" { + t.Errorf("expected com.example.plugin, got %s", results[0].ID) + } + if results[0].Version != "1.0.0" { + t.Errorf("expected 1.0.0, got %s", results[0].Version) + } + if results[0].Source != "dropins" { + t.Errorf("expected dropins source, got %s", results[0].Source) + } +} + +func TestCollectDropins_Empty(t *testing.T) { + mock := executor.NewMock() + det := &ExtensionDetector{exec: mock} + results := det.collectDropins("/nonexistent") + if len(results) != 0 { + t.Errorf("expected 0, got %d", len(results)) + } +} + +func TestParseEclipsePluginName(t *testing.T) { + tests := []struct { + input string + id string + version string + }{ + {"com.github.spotbugs.plugin.eclipse_4.9.8.r202510181643-c1fa7f2", "com.github.spotbugs.plugin.eclipse", "4.9.8.r202510181643-c1fa7f2"}, + {"org.eclipse.jdt.core_3.36.0.v20240103-1234", "org.eclipse.jdt.core", "3.36.0.v20240103-1234"}, + {"simple.plugin_1.0", "simple.plugin", "1.0"}, + } + + for _, tt := range tests { + ext := parseEclipsePluginName(tt.input) + if ext == nil { + t.Errorf("parseEclipsePluginName(%q) returned nil", tt.input) + continue + } + if ext.ID != tt.id { + t.Errorf("ID: expected %s, got %s", tt.id, ext.ID) + } + if ext.Version != tt.version { + t.Errorf("Version: expected %s, got %s", tt.version, ext.Version) + } + } +} + +func TestParseEclipsePluginName_Invalid(t *testing.T) { + invalid := []string{ + "nounderscore", + "no_version_starts_with_letter", + "", + } + for _, input := range invalid { + if ext := parseEclipsePluginName(input); ext != nil { + t.Errorf("parseEclipsePluginName(%q) should return nil, got %+v", input, ext) + } + } +} + +func TestIsEclipseBundled(t *testing.T) { + bundled := []string{"org.eclipse.jdt.core", "javax.annotation", "ch.qos.logback.classic", "bcprov"} + for _, id := range bundled { + if !isEclipseBundled(id) { + t.Errorf("%q should be bundled", id) + } + } + + notBundled := []string{"com.anthropic.claudecode", "civerooni.com.putman", "my.custom.plugin"} + for _, id := range notBundled { + if isEclipseBundled(id) { + t.Errorf("%q should NOT be bundled", id) + } + } +} + +// Ensure model.FilterUserInstalledExtensions works correctly +func TestFilterUserInstalledExtensions(t *testing.T) { + exts := []model.Extension{ + {ID: "bundled1", Source: "bundled"}, + {ID: "marketplace1", Source: "marketplace"}, + {ID: "user1", Source: "user_installed"}, + {ID: "dropin1", Source: "dropins"}, + {ID: "nosource"}, + } + + filtered := model.FilterUserInstalledExtensions(exts) + if len(filtered) != 4 { + t.Fatalf("expected 4 (all except bundled), got %d", len(filtered)) + } + for _, e := range filtered { + if e.Source == "bundled" { + t.Errorf("bundled extension should have been filtered: %s", e.ID) + } + } +} + diff --git a/internal/detector/extension.go b/internal/detector/extension.go index b46bfd0..27046d7 100644 --- a/internal/detector/extension.go +++ b/internal/detector/extension.go @@ -32,7 +32,7 @@ func NewExtensionDetector(exec executor.Executor) *ExtensionDetector { return &ExtensionDetector{exec: exec} } -func (d *ExtensionDetector) Detect(ctx context.Context, searchDirs []string) []model.Extension { +func (d *ExtensionDetector) Detect(ctx context.Context, searchDirs []string, ides []model.IDE) []model.Extension { homeDir := getHomeDir(d.exec) var results []model.Extension @@ -49,8 +49,8 @@ func (d *ExtensionDetector) Detect(ctx context.Context, searchDirs []string) []m // Xcode Source Editor extensions (via macOS pluginkit) results = append(results, d.DetectXcodeExtensions(ctx)...) - // Eclipse plugins (id_version.jar format) - results = append(results, d.DetectEclipsePlugins()...) + // Eclipse plugins — use detected IDE install paths for accurate discovery + results = append(results, d.DetectEclipsePlugins(ctx, ides)...) return results } diff --git a/internal/detector/ide.go b/internal/detector/ide.go index 7aa5406..75150e1 100644 --- a/internal/detector/ide.go +++ b/internal/detector/ide.go @@ -41,19 +41,22 @@ var ideDefinitions = []ideSpec{ { AppName: "Cursor", IDEType: "cursor", Vendor: "Cursor", AppPath: "/Applications/Cursor.app", BinaryPath: "Contents/Resources/app/bin/cursor", - WinPaths: []string{`%LOCALAPPDATA%\Programs\cursor`}, WinBinary: "Cursor.exe", + // Use the .cmd console wrapper, not Cursor.exe (GUI binary that briefly opens a window) + WinPaths: []string{`%LOCALAPPDATA%\Programs\cursor`}, WinBinary: `resources\app\bin\cursor.cmd`, VersionFlag: "--version", }, { AppName: "Windsurf", IDEType: "windsurf", Vendor: "Codeium", AppPath: "/Applications/Windsurf.app", BinaryPath: "Contents/MacOS/Windsurf", - WinPaths: []string{`%LOCALAPPDATA%\Programs\Windsurf`}, WinBinary: "Windsurf.exe", + // Use the .cmd console wrapper to avoid launching the GUI + WinPaths: []string{`%LOCALAPPDATA%\Programs\Windsurf`}, WinBinary: `resources\app\bin\windsurf.cmd`, VersionFlag: "--version", }, { AppName: "Antigravity", IDEType: "antigravity", Vendor: "Google", AppPath: "/Applications/Antigravity.app", BinaryPath: "Contents/MacOS/Antigravity", - WinPaths: []string{`%LOCALAPPDATA%\Programs\Antigravity`}, WinBinary: "Antigravity.exe", + // Use the .cmd console wrapper to avoid launching the GUI + WinPaths: []string{`%LOCALAPPDATA%\Programs\Antigravity`}, WinBinary: `resources\app\bin\antigravity.cmd`, VersionFlag: "--version", }, { diff --git a/internal/detector/jetbrains_plugins_test.go b/internal/detector/jetbrains_plugins_test.go index dcaf07f..384a270 100644 --- a/internal/detector/jetbrains_plugins_test.go +++ b/internal/detector/jetbrains_plugins_test.go @@ -171,7 +171,7 @@ func TestExtensionDetector_IncludesJetBrains(t *testing.T) { `IdeaVim2.10.0JetBrains`)) det := NewExtensionDetector(mock) - results := det.Detect(context.Background(), nil) + results := det.Detect(context.Background(), nil, nil) vscodeCount := 0 jetbrainsCount := 0 diff --git a/internal/model/model.go b/internal/model/model.go index 4f9a18e..5fa523e 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -155,3 +155,15 @@ type PythonScanResult struct { ExitCode int `json:"exit_code"` ScanDurationMs int64 `json:"scan_duration_ms"` } + +// FilterUserInstalledExtensions removes bundled/platform extensions, +// keeping only user-installed, marketplace, and dropins extensions. +func FilterUserInstalledExtensions(exts []Extension) []Extension { + var filtered []Extension + for _, ext := range exts { + if ext.Source != "bundled" { + filtered = append(filtered, ext) + } + } + return filtered +} diff --git a/internal/scan/scanner.go b/internal/scan/scanner.go index 83a2bc3..ea4bf09 100644 --- a/internal/scan/scanner.go +++ b/internal/scan/scanner.go @@ -58,12 +58,19 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { log.StepStart("Collecting IDE extensions") start = time.Now() extDetector := detector.NewExtensionDetector(exec) - extensions := extDetector.Detect(ctx, searchDirs) + extensions := extDetector.Detect(ctx, searchDirs, ides) // Collect JetBrains plugins jbDetector := detector.NewJetBrainsPluginDetector(exec) jbPlugins := jbDetector.Detect(ctx, ides) extensions = append(extensions, jbPlugins...) + + // On Windows, filter out bundled/platform plugins (e.g., Eclipse's 500+ OSGi + // bundles) unless explicitly requested. macOS detection doesn't produce bundled + // plugins in significant volume, so this filter is Windows-only. + if exec.GOOS() == "windows" && !cfg.IncludeBundledPlugins { + extensions = model.FilterUserInstalledExtensions(extensions) + } log.StepDone(time.Since(start)) // Node.js scanning (community mode defaults to off, explicit flag overrides) @@ -254,3 +261,4 @@ func mcpConfigsToCommunity(configs []model.MCPConfig) []model.MCPConfig { } return configs } + diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 2e36d54..29b5c47 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -146,12 +146,18 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { // Collect extensions log.Progress("Scanning extensions...") extDetector := detector.NewExtensionDetector(exec) - extensions := extDetector.Detect(ctx, searchDirs) + extensions := extDetector.Detect(ctx, searchDirs, ides) // Collect JetBrains plugins jbDetector := detector.NewJetBrainsPluginDetector(exec) jbPlugins := jbDetector.Detect(ctx, ides) extensions = append(extensions, jbPlugins...) + + // On Windows, filter out bundled/platform plugins (e.g., Eclipse's 500+ OSGi + // bundles) unless explicitly requested. macOS is unaffected. + if exec.GOOS() == "windows" && !cfg.IncludeBundledPlugins { + extensions = model.FilterUserInstalledExtensions(extensions) + } log.Progress("Found total of %d IDE extensions", len(extensions)) fmt.Fprintln(os.Stderr) @@ -610,3 +616,4 @@ func ideDisplayName(ideType string) string { return ideType } } +