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
11 changes: 9 additions & 2 deletions SCAN_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
114 changes: 102 additions & 12 deletions internal/detector/eclipse_plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
rksharma95 marked this conversation as resolved.

// ---------- 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 <dataArea>/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 ----------
Expand Down
187 changes: 187 additions & 0 deletions internal/detector/eclipse_plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading