diff --git a/crates/ledger-cli/tests/translocation.rs b/crates/ledger-cli/tests/translocation.rs new file mode 100644 index 0000000..c4fed96 --- /dev/null +++ b/crates/ledger-cli/tests/translocation.rs @@ -0,0 +1,133 @@ +//! A macOS app run translocated refuses to connect an agent (P11a step 6): +//! Gatekeeper runs a quarantined download from a read-only copy under +//! `/private/var/folders/…/AppTranslocation/…`, and that path goes away +//! with the app; an agent pointed at it would lose the books. The owner +//! moves the app out of Downloads first. +//! +//! The check is on the path the program would give the agent +//! (`ledger_host::start::this_executable`), so a test can stand in for +//! macOS on any Unix: `APPIMAGE` names a file there, and a path with an +//! `AppTranslocation` part is refused just as the real one is. + +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const KNOBS: &str = + "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300"; + +struct Home { + dir: tempfile::TempDir, +} + +impl Home { + fn new() -> Self { + let dir = tempfile::Builder::new() + .prefix("wb-translocation-") + .tempdir() + .unwrap(); + let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks")) + .args(["setup", "--home"]) + .arg(dir.path()) + .env_remove("XDG_DATA_HOME") + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + Self { dir } + } + + fn root(&self) -> PathBuf { + ledger_store::root::default_root(self.dir.path()) + } + + /// The program with a root of its own and `PATH` holding the stand-in + /// agent. Nothing here touches the account's real data root. + fn cmd(&self, mode: &str, path: &Path) -> Command { + let mut c = Command::new(env!("CARGO_BIN_EXE_wanderbooks")); + c.arg(mode) + .arg("--root") + .arg(self.root()) + .args(["--test-knobs", KNOBS]) + .env_clear() + .env("PATH", format!("{}:/usr/bin:/bin", path.display())) + .env("HOME", self.dir.path()) + .stdin(Stdio::null()); + c + } +} + +/// A stand-in `claude` that leaves a mark when it is run, so the test can +/// say the agent never was. +fn fake_agent(home: &Home) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let bin = home.dir.path().join("agent-bin"); + std::fs::create_dir_all(&bin).unwrap(); + let script = bin.join("claude"); + std::fs::write( + &script, + format!( + "#!/bin/sh\ntouch '{}'\nexit 0\n", + home.dir.path().join("agent-ran").display() + ), + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + bin +} + +#[test] +fn translocated_app_refuses_to_connect() { + let home = Home::new(); + let out = home + .cmd("books", Path::new("/nonexistent")) + .args(["create", "beta"]) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + + let bin = fake_agent(&home); + // What macOS hands the program from Gatekeeper's copy: a path under + // AppTranslocation. A bare file is enough (this_executable only asks + // that it is one). + let translocated = home.dir.path().join( + "private/var/folders/x/AppTranslocation/uuid/d/Wanderbooks.app/Contents/MacOS/wanderbooks", + ); + std::fs::create_dir_all(translocated.parent().unwrap()).unwrap(); + std::fs::write(&translocated, b"").unwrap(); + let out = home + .cmd("connect", &bin) + .env("APPIMAGE", &translocated) + .args(["claude", "--book", "beta"]) + .output() + .unwrap(); + assert!( + !out.status.success(), + "a translocated app connected an agent" + ); + let said = String::from_utf8_lossy(&out.stderr); + assert!(said.contains("move wanderbooks out of Downloads"), "{said}"); + // Refused before anything was done: no agent ran, no books home was + // made for a path that will vanish. + assert!(!home.dir.path().join("agent-ran").exists(), "the agent ran"); + assert!( + !home.dir.path().join("Documents/Wanderbooks/beta").exists(), + "a books home was made" + ); + + // The same program at an ordinary path connects: it is the + // translocation, not the agent or the books, that was refused. + let ordinary = home.dir.path().join("Wanderbooks.AppImage"); + std::fs::write(&ordinary, b"").unwrap(); + let out = home + .cmd("connect", &bin) + .env("APPIMAGE", &ordinary) + .args(["claude", "--book", "beta"]) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + assert!( + home.dir.path().join("agent-ran").exists(), + "the agent wasn't run" + ); +} diff --git a/crates/ledger-host/src/commands.rs b/crates/ledger-host/src/commands.rs index aef7d0a..72cb68f 100644 --- a/crates/ledger-host/src/commands.rs +++ b/crates/ledger-host/src/commands.rs @@ -1262,6 +1262,12 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result Result = vec![ exe.into_os_string(), diff --git a/crates/ledger-host/src/start.rs b/crates/ledger-host/src/start.rs index 931fc2e..a31a16c 100644 --- a/crates/ledger-host/src/start.rs +++ b/crates/ledger-host/src/start.rs @@ -83,6 +83,21 @@ pub fn this_executable() -> Result { Ok(exe) } +/// Why this program can't point an agent at itself, if it can't (P11a +/// step 6): macOS runs a quarantined app from a read-only copy under +/// `/private/var/folders/…/AppTranslocation/…`, a path that goes away when +/// the app closes. The app may run, but an agent connected from it would +/// lose the books, so the person moves it out of Downloads first. +/// `exe` is the path this program would give the agent +/// ([`this_executable`]). +pub fn translocation_refusal(exe: &Path) -> Option<&'static str> { + exe.components() + .any(|part| part.as_os_str() == "AppTranslocation") + .then_some( + "wanderbooks is running from the copy macOS makes of a quarantined download, and that copy goes away when the app closes: move wanderbooks out of Downloads (Applications, for example), open it there, and connect the agent again", + ) +} + /// The AppImage this process runs from: `APPIMAGE` when it is an /// absolute path to a file. pub fn own_appimage(value: Option<&std::ffi::OsStr>) -> Option { @@ -633,6 +648,28 @@ mod tests { let exe = this_executable().unwrap(); assert!(exe.is_file(), "{exe:?}"); } + + #[test] + fn only_a_translocated_path_refuses_an_agent() { + assert_eq!( + translocation_refusal(Path::new( + "/Applications/Wanderbooks.app/Contents/MacOS/wanderbooks" + )), + None + ); + let copy = translocation_refusal(Path::new( + "/private/var/folders/xy/T/AppTranslocation/1234/d/Wanderbooks.app/Contents/MacOS/wanderbooks", + )) + .expect("a translocated path is refused"); + assert!(copy.contains("out of Downloads"), "{copy}"); + // A part that only shares letters is not the marker macOS makes. + assert_eq!( + translocation_refusal(Path::new( + "/Applications/AppTranslocation Notes/wanderbooks" + )), + None + ); + } } #[cfg(all(test, windows))]