mirror of
https://github.com/storytold/craft-anything.git
synced 2026-11-05 20:47:45 +01:00
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).
This commit is contained in:
@@ -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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
/// 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<Cancel>, progress: &dyn Fn(&str, u64, Option<u64>)) -> Result<PathBuf, ToolError> {
|
||||
// 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.")));
|
||||
|
||||
@@ -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/<version>/ghidra_<version>_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");
|
||||
}
|
||||
}
|
||||
@@ -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 (`<store>/<id>/bin/<exe>`), 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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
roots.iter().find_map(|(root, depth)| search_named(root, id, exes, *depth))
|
||||
}
|
||||
|
||||
fn search_named(dir: &Path, id: &str, exes: &[&str], depth: usize) -> Option<PathBuf> {
|
||||
if depth == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut subs: Vec<PathBuf> = 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<PathBuf> {
|
||||
|
||||
@@ -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/<version>/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),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user