diff --git a/SCAN_COVERAGE.md b/SCAN_COVERAGE.md index 273a96e..243790a 100644 --- a/SCAN_COVERAGE.md +++ b/SCAN_COVERAGE.md @@ -134,10 +134,17 @@ Only user-installed plugins are reported by default. Use `--include-bundled-plug | Platform | Detection Method | |----------|---------------------------------------------------------------------------------| -| macOS | Scans `features/` and `dropins/` within the Eclipse app bundle | +| macOS | Scans `features/` and `dropins/` within the Eclipse app bundle, plus `features/` in the p2 shared bundle pool (`~/.p2/pool` by default) | | Windows | Multi-stage: detected IDE paths, well-known paths, registry, drive letter probes; validates with `.ini` + `plugins/` + `configuration/`; uses p2 director and `bundles.info` for feature lists | -Plugins are classified as `bundled`, `marketplace`, or `dropins` based on their location and bundle ID prefix. +Installs created by the Eclipse Installer keep their units in a shared p2 bundle +pool rather than under the app bundle — `features/` is absent and `plugins/` +holds only the launcher. The pool is located from `eclipse.p2.data.area` in +`configuration/config.ini`, falling back to `~/.p2/pool`. Only feature groups +are reported, so each installed product is one entry rather than the dozens of +OSGi bundles it ships. + +Plugins are classified as `bundled`, `marketplace`, or `dropins` based on their location and bundle ID prefix. On Windows, bundled units are filtered out of the report unless `--include-bundled-plugins` is passed. ### Xcode Extensions (macOS only) diff --git a/internal/detector/eclipse_plugins.go b/internal/detector/eclipse_plugins.go index f6132b2..347de77 100644 --- a/internal/detector/eclipse_plugins.go +++ b/internal/detector/eclipse_plugins.go @@ -84,30 +84,120 @@ var eclipseIniPatterns = []string{ "myeclipse.ini", } -// ---------- macOS detection (unchanged) ---------- +// ---------- macOS detection ---------- -var eclipseFeatureDirsDarwin = []string{ - "/Applications/Eclipse.app/Contents/Eclipse/features", - "/Applications/Eclipse.app/Contents/Eclipse/dropins", -} +// eclipseInstallRootDarwin is the Eclipse install directory inside the macOS +// app bundle — the equivalent of the install dir scanned on Windows. +const eclipseInstallRootDarwin = "/Applications/Eclipse.app/Contents/Eclipse" // ---------- Public API ---------- // DetectEclipsePlugins scans Eclipse installations for plugins. -// On macOS: scans features/dropins directories. +// On macOS: scans the install-local features/dropins directories plus the +// features directory of the p2 shared bundle pool, when the install uses one. // 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() != model.PlatformWindows { - var results []model.Extension - for _, dir := range eclipseFeatureDirsDarwin { - if d.exec.DirExists(dir) { - results = append(results, d.collectEclipseFeatures(dir)...) + return d.detectEclipsePluginsDarwin() + } + return d.detectEclipsePluginsWindows(ctx, ides) +} + +// detectEclipsePluginsDarwin enumerates plugins for the macOS app-bundle install. +// +// Feature groups are the unit worth reporting: one entry per installed product +// (e.g. software.aws.toolkits.eclipse.amazonq.feature) rather than each of the +// dozens of OSGi bundles it ships. Installs created by the Eclipse Installer +// keep those features in a shared p2 bundle pool outside the app bundle, so the +// pool's features dir is scanned alongside the install-local one — which still +// covers self-contained installs that use no pool. +func (d *ExtensionDetector) detectEclipsePluginsDarwin() []model.Extension { + installDir := eclipseInstallRootDarwin + if !d.exec.DirExists(installDir) { + return nil + } + + seen := make(map[string]bool) + var results []model.Extension + add := func(exts []model.Extension) { + for _, ext := range exts { + key := ext.ID + "@" + ext.Version + if seen[key] { + continue } + seen[key] = true + results = append(results, ext) } - return results } - return d.detectEclipsePluginsWindows(ctx, ides) + + // Install-local features (self-contained installs). + installFeatures := filepath.Join(installDir, "features") + if d.exec.DirExists(installFeatures) { + add(d.collectEclipseFeatures(installFeatures)) + } + + // Features from the shared bundle pool, when the install uses one. + if pool := d.resolveEclipsePoolDir(installDir); pool != "" { + poolFeatures := filepath.Join(pool, "features") + if d.exec.DirExists(poolFeatures) { + add(d.collectEclipseFeatures(poolFeatures)) + } + } + + // Dropins — collectDropins also handles nested eclipse/plugins layouts and + // tags the source as "dropins". + add(d.collectDropins(filepath.Join(installDir, "dropins"))) + + return results +} + +// resolveEclipsePoolDir locates the p2 shared bundle pool for an install. +// +// The agent's data area is recorded in configuration/config.ini as a Java +// properties entry with escaped colons, e.g. +// +// eclipse.p2.data.area=file\:/Users/alice/.p2/ +// +// and the pool sits at /pool. Falls back to ~/.p2/pool, the default +// the Eclipse Installer uses, when config.ini carries no such entry. Returns "" +// if neither resolves to an existing directory. +func (d *ExtensionDetector) resolveEclipsePoolDir(installDir string) string { + if dataArea := d.readEclipseP2DataArea(filepath.Join(installDir, "configuration", "config.ini")); dataArea != "" { + if pool := filepath.Join(dataArea, "pool"); d.exec.DirExists(pool) { + return pool + } + } + if home := getHomeDir(d.exec); home != "" { + if pool := filepath.Join(home, ".p2", "pool"); d.exec.DirExists(pool) { + return pool + } + } + return "" +} + +// readEclipseP2DataArea reads the eclipse.p2.data.area property from a +// config.ini and normalizes it to a filesystem path. +func (d *ExtensionDetector) readEclipseP2DataArea(configINI string) string { + data, err := d.exec.ReadFile(configINI) + if err != nil { + return "" + } + const key = "eclipse.p2.data.area=" + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, key) { + continue + } + // Java properties escape the URI colon as "\:". + value := strings.ReplaceAll(strings.TrimPrefix(line, key), `\:`, ":") + value = strings.TrimPrefix(value, "file:") + if value == "" { + continue + } + return filepath.Clean(value) + } + return "" } // ---------- Windows multi-stage pipeline ---------- diff --git a/internal/detector/eclipse_plugins_test.go b/internal/detector/eclipse_plugins_test.go index d147006..34d9c22 100644 --- a/internal/detector/eclipse_plugins_test.go +++ b/internal/detector/eclipse_plugins_test.go @@ -336,3 +336,190 @@ func TestFilterUserInstalledExtensions(t *testing.T) { } } } + +// ---------- macOS shared-bundle-pool detection ---------- + +func TestReadEclipseP2DataArea(t *testing.T) { + mock := executor.NewMock() + configINI := "/Applications/Eclipse.app/Contents/Eclipse/configuration/config.ini" + mock.SetFile(configINI, []byte(`#This configuration file was written by: org.eclipse.oomph.p2.internal.core.AgentImpl +eclipse.application=org.eclipse.ui.ide.workbench +eclipse.p2.data.area=file\:/Users/testuser/.p2/ +eclipse.p2.profile=_Applications_Eclipse.app_Contents_Eclipse +`)) + + det := &ExtensionDetector{exec: mock} + if got := det.readEclipseP2DataArea(configINI); got != filepath.Clean("/Users/testuser/.p2") { + t.Errorf("expected /Users/testuser/.p2, got %q", got) + } +} + +func TestReadEclipseP2DataArea_Missing(t *testing.T) { + mock := executor.NewMock() + configINI := "/eclipse/configuration/config.ini" + mock.SetFile(configINI, []byte("eclipse.application=org.eclipse.ui.ide.workbench\n")) + + det := &ExtensionDetector{exec: mock} + if got := det.readEclipseP2DataArea(configINI); got != "" { + t.Errorf("expected empty when property absent, got %q", got) + } +} + +func TestResolveEclipsePoolDir_FromConfigINI(t *testing.T) { + mock := executor.NewMock() + installDir := eclipseInstallRootDarwin + mock.SetFile(filepath.Join(installDir, "configuration", "config.ini"), + []byte(`eclipse.p2.data.area=file\:/Users/testuser/.p2/`+"\n")) + mock.SetDir("/Users/testuser/.p2/pool") + + det := &ExtensionDetector{exec: mock} + if got := det.resolveEclipsePoolDir(installDir); got != filepath.Clean("/Users/testuser/.p2/pool") { + t.Errorf("expected pool from config.ini, got %q", got) + } +} + +func TestResolveEclipsePoolDir_FallsBackToHome(t *testing.T) { + mock := executor.NewMock() + mock.SetHomeDir("/Users/testuser") + mock.SetDir("/Users/testuser/.p2/pool") + + det := &ExtensionDetector{exec: mock} + // No config.ini — should fall back to ~/.p2/pool. + if got := det.resolveEclipsePoolDir(eclipseInstallRootDarwin); got != filepath.Join("/Users/testuser", ".p2", "pool") { + t.Errorf("expected ~/.p2/pool fallback, got %q", got) + } +} + +func TestResolveEclipsePoolDir_NoPool(t *testing.T) { + mock := executor.NewMock() + mock.SetHomeDir("/Users/testuser") + + det := &ExtensionDetector{exec: mock} + if got := det.resolveEclipsePoolDir(eclipseInstallRootDarwin); got != "" { + t.Errorf("expected empty when no pool exists, got %q", got) + } +} + +// A macOS install created by the Eclipse Installer keeps its units in a shared +// p2 pool: features/ is absent under the app bundle and plugins/ holds only the +// launcher, so installed products are visible solely via the pool's features. +func TestDetectEclipsePluginsDarwin_SharedBundlePool(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("darwin") + mock.SetHomeDir("/Users/testuser") + + installDir := eclipseInstallRootDarwin + pool := "/Users/testuser/.p2/pool" + mock.SetDir(installDir) + mock.SetFile(filepath.Join(installDir, "configuration", "config.ini"), + []byte(`eclipse.p2.data.area=file\:/Users/testuser/.p2/`+"\n")) + mock.SetDir(pool) + + // Feature groups live in the pool. No install-local features/ dir exists. + mock.SetDir(filepath.Join(pool, "features")) + mock.SetDirEntries(filepath.Join(pool, "features"), []os.DirEntry{ + executor.MockDirEntry("software.aws.toolkits.eclipse.amazonq.feature_2.7.5.202607231906", true), + executor.MockDirEntry("org.eclipse.jdt_3.20.500", true), + }) + + det := &ExtensionDetector{exec: mock} + results := det.DetectEclipsePlugins(context.Background(), nil) + + byID := make(map[string]model.Extension, len(results)) + for _, r := range results { + byID[r.ID] = r + } + + feature, ok := byID["software.aws.toolkits.eclipse.amazonq.feature"] + if !ok { + t.Fatalf("expected the Amazon Q feature group from the pool, got %v", byID) + } + if feature.Source != "user_installed" { + t.Errorf("expected user_installed feature, got %s", feature.Source) + } + if feature.Version != "2.7.5.202607231906" { + t.Errorf("expected version 2.7.5.202607231906, got %s", feature.Version) + } + wantPath := filepath.Join(pool, "features", "software.aws.toolkits.eclipse.amazonq.feature_2.7.5.202607231906") + if feature.InstallPath != wantPath { + t.Errorf("expected %s, got %s", wantPath, feature.InstallPath) + } + + // Platform features are still collected, tagged bundled for the scanner filter. + if ext, ok := byID["org.eclipse.jdt"]; !ok || ext.Source != "bundled" { + t.Errorf("expected pool feature org.eclipse.jdt tagged bundled, got %+v", ext) + } + + // Individual OSGi bundles are deliberately not reported — only feature groups. + if _, ok := byID["amazon-q-eclipse"]; ok { + t.Error("did not expect individual bundles from bundles.info") + } +} + +func TestDetectEclipsePluginsDarwin_NoInstall(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("darwin") + + det := &ExtensionDetector{exec: mock} + if results := det.DetectEclipsePlugins(context.Background(), nil); len(results) != 0 { + t.Errorf("expected 0 when Eclipse.app is absent, got %d", len(results)) + } +} + +// A self-contained install (tarball layout) keeps everything under the app +// bundle with no p2 pool. +func TestDetectEclipsePluginsDarwin_SelfContained(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("darwin") + mock.SetHomeDir("/Users/testuser") + + installDir := eclipseInstallRootDarwin + mock.SetDir(installDir) + mock.SetDir(filepath.Join(installDir, "features")) + mock.SetDirEntries(filepath.Join(installDir, "features"), []os.DirEntry{ + executor.MockDirEntry("net.sourceforge.pmd.eclipse_7.26.0", true), + }) + + det := &ExtensionDetector{exec: mock} + results := det.DetectEclipsePlugins(context.Background(), nil) + + if len(results) != 1 { + t.Fatalf("expected 1 feature, got %d (%v)", len(results), results) + } + if results[0].ID != "net.sourceforge.pmd.eclipse" || results[0].Source != "user_installed" { + t.Errorf("unexpected result: %+v", results[0]) + } +} + +// A feature can be present both install-locally and in the pool; report it once. +func TestDetectEclipsePluginsDarwin_DedupesAcrossSources(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("darwin") + mock.SetHomeDir("/Users/testuser") + + installDir := eclipseInstallRootDarwin + pool := "/Users/testuser/.p2/pool" + mock.SetDir(installDir) + mock.SetDir(pool) + + installFeatures := filepath.Join(installDir, "features") + mock.SetDir(installFeatures) + mock.SetDirEntries(installFeatures, []os.DirEntry{ + executor.MockDirEntry("com.example.plugin_1.0.0", true), + }) + mock.SetDir(filepath.Join(pool, "features")) + mock.SetDirEntries(filepath.Join(pool, "features"), []os.DirEntry{ + executor.MockDirEntry("com.example.plugin_1.0.0", true), + }) + + det := &ExtensionDetector{exec: mock} + results := det.DetectEclipsePlugins(context.Background(), nil) + + if len(results) != 1 { + t.Fatalf("expected 1 deduped unit, got %d (%v)", len(results), results) + } + // The install-local copy is scanned first and wins. + if results[0].InstallPath != filepath.Join(installFeatures, "com.example.plugin_1.0.0") { + t.Errorf("expected install-local path to win, got %s", results[0].InstallPath) + } +}