A `.dmg` downloaded from the internet keeps its quarantine bit, so Gatekeeper may run the app from a read-only copy under `/private/var/folders/…/AppTranslocation/…` that vanishes when the app closes. Connected from there, an agent would be pointed at a path that will not exist the next time it runs. Connect refuses instead, before a token is made or an agent's list is touched, and says to move the app out of Downloads and open it again (P11a step 6). The rule is on the path the program would give the agent (`start::translocation_refusal` over `this_executable`), so the same check covers the CLI's `connect` and the viewer's Connect. The test stands in for Gatekeeper's copy on any Unix by naming a file under an `AppTranslocation` path in `APPIMAGE`; the agent never runs and no books home is made, while the same program at an ordinary path connects. A unit test holds the marker to a whole path part, not letters in a name.
134 lines
4.5 KiB
Rust
134 lines
4.5 KiB
Rust
//! 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"
|
|
);
|
|
}
|