From 7b5ce085a3bf28fc193d33d2cd6cc464f2892559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Soares?= <85080530+fabox-os@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:08:26 +0000 Subject: [PATCH] Find Ghidra in the folders people unzip it to, and don't download it again (#745) Ghidra is found where people unzip it (Program Files, the home folder, Downloads, Desktop, /Applications, Homebrew's cask and the usual Linux folders), by folder name a set number of levels down, and the guided install uses a copy already on the computer instead of downloading it again (fixes #740). --- crates/engines/src/ghidra/get.rs | 12 +++- crates/engines/src/known_roots_tests.rs | 71 ++++++++++++++++++++++++ crates/engines/src/lib.rs | 30 +++++++++- crates/engines/src/table.rs | 74 ++++++++++++++++++++++++- docs/pitfalls.md | 7 +++ 5 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 crates/engines/src/known_roots_tests.rs diff --git a/crates/engines/src/ghidra/get.rs b/crates/engines/src/ghidra/get.rs index b679e4b2..bc5aafec 100644 --- a/crates/engines/src/ghidra/get.rs +++ b/crates/engines/src/ghidra/get.rs @@ -26,11 +26,16 @@ pub fn can_install() -> bool { } /// Ghidra's headless launcher: in the folder the person picked (Ghidra's own folder, or one that -/// holds it), then the managed install, then PATH, then where system packages put it. +/// holds it), then the managed install, then PATH, then where system packages put it, then the +/// folders where an unpacked copy is usually kept (#740). pub fn headless(install_dir: Option<&Path>, store: &Path) -> Option { headless_configured(install_dir, store) .or_else(|| crate::find_on_path(&["analyzeHeadless.bat", "analyzeHeadless"])) .or_else(|| crate::well_known(ID).into_iter().find(|p| p.is_file())) + .or_else(|| { + let e = crate::engine(ID)?; + crate::find_in_known_roots(ID, e.host_exes(), &e.known_root_folders()) + }) } /// Ghidra's headless launcher in the folder the person picked or the managed install only: what @@ -57,6 +62,11 @@ pub fn java_home(java_home: Option<&Path>, store: &Path) -> Option { /// store, then point Ghidra at that Java. `progress` gets (what, bytes so far, total); a step with /// no bytes (0, `None`) is setting up. Returns the headless launcher. pub fn install(store: &Path, cancel: Option, progress: &dyn Fn(&str, u64, Option)) -> Result { + // Ghidra is already on this computer, outside our store (PATH, a system package, an unzipped + // copy in Downloads…): use it rather than download the same release again (#740). + if let Some(found) = headless(None, store).filter(|p| !p.starts_with(store)) { + return Ok(found); + } let (Some(ghidra), Some(java)) = (pin(ID), pin("java")) else { return Err(ToolError::new(ErrorCode::NotSupported, "Ghidra can't be installed for you on this computer.") .with_remediation(format!("Install Ghidra {VERSION} from {PAGE}, then choose its folder in Settings → Engines."))); diff --git a/crates/engines/src/known_roots_tests.rs b/crates/engines/src/known_roots_tests.rs new file mode 100644 index 00000000..ad0a247d --- /dev/null +++ b/crates/engines/src/known_roots_tests.rs @@ -0,0 +1,71 @@ +//! Unpacked installs found in the folders where people keep them (#740). Each case has its own temp +//! folder; nothing here reads the real home folder or Program Files. + +use super::*; + +fn touch(p: &Path) { + std::fs::create_dir_all(p.parent().expect("a file has a parent")).expect("create folders"); + std::fs::write(p, b"x").expect("write file"); +} + +#[test] +fn an_unzipped_release_next_to_its_folder_name_is_found() { + let d = tempfile::tempdir().expect("tempdir"); + let launcher = d.path().join("ghidra_12.1.4_PUBLIC").join("support").join("analyzeHeadless"); + touch(&launcher); + let roots = [(d.path().to_path_buf(), 1)]; + assert_eq!(find_in_known_roots("ghidra", &["analyzeHeadless"], &roots), Some(launcher)); +} + +#[test] +fn a_capitalised_folder_name_is_found_too() { + let d = tempfile::tempdir().expect("tempdir"); + let launcher = d.path().join("Ghidra").join("support").join("analyzeHeadless.bat"); + touch(&launcher); + assert_eq!(find_in_known_roots("ghidra", &["analyzeHeadless.bat"], &[(d.path().to_path_buf(), 1)]), Some(launcher)); +} + +#[test] +fn a_package_manager_folder_two_levels_down_is_found_when_the_depth_allows_it() { + let d = tempfile::tempdir().expect("tempdir"); + // Homebrew: Caskroom/ghidra//ghidra__PUBLIC/support/analyzeHeadless + let caskroom = d.path().join("ghidra"); + let launcher = caskroom.join("11.4").join("ghidra_11.4_PUBLIC").join("support").join("analyzeHeadless"); + touch(&launcher); + assert_eq!(find_in_known_roots("ghidra", &["analyzeHeadless"], &[(caskroom.clone(), 2)]), Some(launcher)); + assert_eq!(find_in_known_roots("ghidra", &["analyzeHeadless"], &[(caskroom, 1)]), None, "one level is not enough"); +} + +#[test] +fn a_newer_version_is_preferred() { + let d = tempfile::tempdir().expect("tempdir"); + let old = d.path().join("ghidra_11.4_PUBLIC").join("support").join("analyzeHeadless"); + let new = d.path().join("ghidra_12.1.4_PUBLIC").join("support").join("analyzeHeadless"); + touch(&old); + touch(&new); + assert_eq!(find_in_known_roots("ghidra", &["analyzeHeadless"], &[(d.path().to_path_buf(), 1)]), Some(new)); +} + +#[test] +fn other_programs_and_missing_folders_are_ignored() { + let d = tempfile::tempdir().expect("tempdir"); + touch(&d.path().join("other_tool").join("support").join("analyzeHeadless")); + let missing = d.path().join("not-there"); + assert_eq!(find_in_known_roots("ghidra", &["analyzeHeadless"], &[(d.path().to_path_buf(), 1), (missing, 1)]), None); + assert_eq!(find_in_known_roots("ghidra", &["analyzeHeadless"], &[]), None); +} + +#[test] +fn a_folder_without_the_launcher_is_not_an_install() { + let d = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(d.path().join("ghidra_12.1.4_PUBLIC").join("Ghidra")).expect("create folders"); + assert_eq!(find_in_known_roots("ghidra", &["analyzeHeadless"], &[(d.path().to_path_buf(), 1)]), None); +} + +#[test] +fn every_host_folder_for_ghidra_resolves_without_panicking() { + let ghidra = engine("ghidra").expect("ghidra is in the table"); + for (folder, depth) in ghidra.known_root_folders() { + assert!(depth >= 1, "{folder:?} must look at least one level down"); + } +} diff --git a/crates/engines/src/lib.rs b/crates/engines/src/lib.rs index 8d593fea..658e12dd 100644 --- a/crates/engines/src/lib.rs +++ b/crates/engines/src/lib.rs @@ -15,6 +15,8 @@ pub mod gdb; pub mod ghidra; pub mod install; pub mod jadx; +#[cfg(test)] +mod known_roots_tests; pub mod mcp_client; pub mod rizin; pub mod table; @@ -45,11 +47,13 @@ pub fn display_name(id: &str) -> &'static str { } /// Find an engine: user override, then the managed store (`//bin/`), then PATH, -/// then its standard install folders. +/// then its standard install folders, then the folders where an unpacked copy is usually kept. pub fn locate(id: &str, overrides: &dyn ToolPathLookup, store: &Path) -> Option { locate_configured(id, overrides, store).or_else(|| { let e = engine(id)?; - find_on_path(e.host_exes()).or_else(|| e.known_install_paths().into_iter().find(|p| p.is_file())) + find_on_path(e.host_exes()) + .or_else(|| e.known_install_paths().into_iter().find(|p| p.is_file())) + .or_else(|| find_in_known_roots(id, e.host_exes(), &e.known_root_folders())) }) } @@ -90,6 +94,28 @@ fn in_managed(dir: &Path, exes: &[&str]) -> Option { subs.iter().rev().find_map(|d| here(d)) } +/// An unpacked install in one of `roots` (each with how many levels down to look): a folder whose +/// name starts with the engine id (`ghidra`, `Ghidra`, `ghidra_12.1.4_PUBLIC`) and holds one of +/// `exes` (see [`in_managed`]). Newer names first. Reads folder names only, never file contents. +pub fn find_in_known_roots(id: &str, exes: &[&str], roots: &[(PathBuf, usize)]) -> Option { + roots.iter().find_map(|(root, depth)| search_named(root, id, exes, *depth)) +} + +fn search_named(dir: &Path, id: &str, exes: &[&str], depth: usize) -> Option { + if depth == 0 { + return None; + } + let mut subs: Vec = std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect(); + subs.sort(); + subs.iter().rev().find_map(|d| { + let named = d.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.to_ascii_lowercase().starts_with(id)); + if named && let Some(p) = in_managed(d, exes) { + return Some(p); + } + search_named(d, id, exes, depth - 1) + }) +} + /// Standard install locations for programs that don't put themselves on PATH (browsers on /// Windows and macOS, Ghidra from Linux packages), most common first. pub fn well_known(id: &str) -> Vec { diff --git a/crates/engines/src/table.rs b/crates/engines/src/table.rs index 9849fdd6..816536de 100644 --- a/crates/engines/src/table.rs +++ b/crates/engines/src/table.rs @@ -78,6 +78,20 @@ pub struct KnownPath { pub rel: &'static str, } +/// A folder that may hold an unpacked install: any sub-folder named after the engine (`ghidra_12.1.4_PUBLIC`, +/// `ghidra/11.4/…`) is searched, down to `depth` levels. Where people unzip a release, or where a +/// package manager unpacks one, when it isn't on PATH. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KnownRoot { + /// Host OS as in `std::env::consts::OS`. + pub os: &'static str, + pub base: Base, + /// Folder relative to `base` (`""` for `base` itself, `"Downloads"`). + pub rel: &'static str, + /// How many folder levels below the root to look (1: the root's own sub-folders). + pub depth: usize, +} + /// One optional external engine. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ExternalEngine { @@ -93,6 +107,8 @@ pub struct ExternalEngine { pub exes_windows: &'static [&'static str], /// Standard install locations, searched after PATH. pub known_paths: &'static [KnownPath], + /// Folders searched for an unpacked install of this engine, after the standard install locations. + pub known_roots: &'static [KnownRoot], /// Whether Settings → Engines has a path override for it (`ToolPaths`). Ghidra has its own folder setting. pub path_setting: bool, pub install: Install, @@ -134,6 +150,22 @@ impl ExternalEngine { }) .collect() } + + /// The folders to search for an unpacked install on this OS, with how many levels down to look. + pub fn known_root_folders(&self) -> Vec<(std::path::PathBuf, usize)> { + let os = std::env::consts::OS; + self.known_roots + .iter() + .filter(|k| k.os == os) + .filter_map(|k| { + let base = match k.base { + Base::Env(var) => std::path::PathBuf::from(std::env::var_os(var)?), + Base::Dir(dir) => std::path::PathBuf::from(dir), + }; + Some((base.join(k.rel), k.depth)) + }) + .collect() + } } const fn win(base: &'static str, rel: &'static str) -> KnownPath { @@ -154,6 +186,38 @@ const NO_PATHS: &[KnownPath] = &[]; /// Arch and its derivatives in `/opt/ghidra`, Debian and Kali in `/usr/share/ghidra` (#85). const GHIDRA_PATHS: &[KnownPath] = &[linux("/opt/ghidra", "support/analyzeHeadless"), linux("/usr/share/ghidra", "support/analyzeHeadless")]; +const NO_ROOTS: &[KnownRoot] = &[]; + +const fn root(os: &'static str, base: Base, rel: &'static str, depth: usize) -> KnownRoot { + KnownRoot { os, base, rel, depth } +} + +/// Ghidra is unzipped by hand almost anywhere (#740): Program Files, the home folder, Downloads and +/// Desktop, and `/Applications`. Homebrew's cask unpacks to `Caskroom/ghidra//ghidra_…`, two +/// levels down. Linux packages and the Arch/Debian folders sit under the system's share and library +/// folders, one level down. +const GHIDRA_ROOTS: &[KnownRoot] = &[ + root("windows", Base::Env("ProgramFiles"), "", 1), + root("windows", Base::Env("ProgramFiles(x86)"), "", 1), + root("windows", Base::Env("USERPROFILE"), "", 1), + root("windows", Base::Env("USERPROFILE"), "Downloads", 1), + root("windows", Base::Env("USERPROFILE"), "Desktop", 1), + root("macos", Base::Dir("/Applications"), "", 1), + root("macos", Base::Env("HOME"), "", 1), + root("macos", Base::Env("HOME"), "Downloads", 1), + root("macos", Base::Env("HOME"), "Desktop", 1), + root("macos", Base::Dir("/opt/homebrew/Caskroom/ghidra"), "", 2), + root("macos", Base::Dir("/usr/local/Caskroom/ghidra"), "", 2), + root("linux", Base::Dir("/opt"), "", 1), + root("linux", Base::Dir("/usr/share"), "", 1), + root("linux", Base::Dir("/usr/lib"), "", 1), + root("linux", Base::Dir("/usr/local/share"), "", 1), + root("linux", Base::Dir("/usr/local/lib"), "", 1), + root("linux", Base::Env("HOME"), "", 1), + root("linux", Base::Env("HOME"), "Downloads", 1), + root("linux", Base::Env("HOME"), "Desktop", 1), +]; + /// Browsers on Windows and macOS rarely put themselves on PATH. const CHROMIUM_PATHS: &[KnownPath] = &[ win("ProgramFiles", "Google/Chrome/Application/chrome.exe"), @@ -318,7 +382,7 @@ const fn manual( exes: &'static [&'static str], exes_windows: &'static [&'static str], ) -> ExternalEngine { - ExternalEngine { id, name, needed_for, exes, exes_windows, known_paths: NO_PATHS, path_setting: true, install: Install::Manual } + ExternalEngine { id, name, needed_for, exes, exes_windows, known_paths: NO_PATHS, known_roots: NO_ROOTS, path_setting: true, install: Install::Manual } } /// Every external engine. Ghidra first, then in the order of the Settings → Engines path list @@ -331,6 +395,7 @@ pub const ENGINES: &[ExternalEngine] = &[ exes: &["analyzeHeadless"], exes_windows: &["analyzeHeadless.bat"], known_paths: GHIDRA_PATHS, + known_roots: GHIDRA_ROOTS, path_setting: false, install: Install::Guided { pins: GHIDRA_PINS, how: GHIDRA_SETUP }, }, @@ -341,6 +406,7 @@ pub const ENGINES: &[ExternalEngine] = &[ exes: &["adb"], exes_windows: &["adb.exe"], known_paths: NO_PATHS, + known_roots: NO_ROOTS, path_setting: true, install: Install::Pinned(ADB_PINS), }, @@ -351,6 +417,7 @@ pub const ENGINES: &[ExternalEngine] = &[ exes: &["jadx"], exes_windows: &["jadx.bat"], known_paths: NO_PATHS, + known_roots: NO_ROOTS, path_setting: true, install: Install::Pinned(JADX_PINS), }, @@ -361,6 +428,7 @@ pub const ENGINES: &[ExternalEngine] = &[ exes: &["apktool"], exes_windows: &["apktool.bat"], known_paths: NO_PATHS, + known_roots: NO_ROOTS, path_setting: true, install: Install::Pinned(APKTOOL_PINS), }, @@ -371,6 +439,7 @@ pub const ENGINES: &[ExternalEngine] = &[ exes: &["chromium", "chromium-browser", "google-chrome", "chrome", "msedge", "brave"], exes_windows: &["chrome.exe", "msedge.exe", "brave.exe"], known_paths: CHROMIUM_PATHS, + known_roots: NO_ROOTS, path_setting: true, install: Install::Manual, }, @@ -388,6 +457,7 @@ pub const ENGINES: &[ExternalEngine] = &[ exes: &["java"], exes_windows: &["java.exe"], known_paths: NO_PATHS, + known_roots: NO_ROOTS, path_setting: true, install: Install::Guided { pins: JAVA_PINS, how: GHIDRA_SETUP }, }, @@ -400,6 +470,7 @@ pub const ENGINES: &[ExternalEngine] = &[ exes: &["rizin"], exes_windows: &["rizin.exe"], known_paths: NO_PATHS, + known_roots: NO_ROOTS, path_setting: true, install: Install::Pinned(RIZIN_PINS), }, @@ -411,6 +482,7 @@ pub const ENGINES: &[ExternalEngine] = &[ exes: &["vineflower"], exes_windows: &["vineflower.bat", "vineflower.cmd"], known_paths: NO_PATHS, + known_roots: NO_ROOTS, path_setting: true, install: Install::Pinned(VINEFLOWER_PINS), }, diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 92767df0..b25e526c 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -564,6 +564,13 @@ three more times. Anything a project keeps must still work where the folder goes points to as well (`agent-session.jsonl`), tell "not here" apart from a failure by a typed signal (`Conversation::session_lost`), and never retry what can't succeed. +**An installer that downloaded what was already there.** Ghidra was found only on PATH and at two +Linux folders, so an unzipped copy in Downloads, Program Files or a Homebrew cask was missed, and +the install then downloaded the same release again (#740). Install folders are a search, not a +list: scan the folders people keep such things in (`KnownRoot`, by folder name, a set number of +levels down), and make an install use a copy already found outside our store. Test each layout +(unzipped, cask, capitalised name) with temp folders, not the machine the test runs on. + ## Agent tooling **Shared CLI efforts reached Claude unchanged.** Keep CLI-only levels available for agents, but