diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 231444f..0094a6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,11 @@ # program linked on a newer one does not start on an older one), attach # them to the GitHub release, and add them to the apt and dnf repositories # on the gh-pages branch (GitHub Pages serves them). Unsigned: step 5 of -# the unit (the release key, Q-4) signs the manifest and the indexes, and -# step 6 adds the macOS and Windows jobs. The release holds no key today. +# the unit (the release key, Q-4) signs the manifest and the indexes. The +# macOS job builds the .dmg UNSIGNED (no Developer ID, no notarization: +# step 5); the Windows job is unit P11c. The release holds no key today. +# To see the artifacts without a tag: Actions > release > Run workflow, +# pick the branch; each job uploads its files as a run artifact. name: release on: @@ -75,3 +78,46 @@ jobs: git add -A git commit -m "wanderbooks $v" git push origin HEAD:gh-pages + - name: Keep them with the run (a dispatch has no release) + uses: actions/upload-artifact@v4 + with: + name: linux-packages + path: target/packages/${{ steps.version.outputs.version }}/ + + macos: + # UNSIGNED: a .dmg that Gatekeeper refuses until step 5 signs and + # notarizes it. Apple silicon (macos-latest); an Intel build is not + # made yet. + runs-on: macos-latest + env: + CARGO_INCREMENTAL: "0" + steps: + - uses: actions/checkout@v4 + - uses: jdx/mise-action@v2 + with: + install_args: "rust just cargo:cargo-packager" + - name: The tag is the program's version + id: version + run: | + own=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ledger-cli/Cargo.toml | head -n1) + tag="${GITHUB_REF_NAME#v}" + if [ "${GITHUB_REF_TYPE}" = tag ] && [ "$tag" != "$own" ]; then + echo "tag $GITHUB_REF_NAME is not version $own" >&2 + exit 1 + fi + echo "version=$own" >> "$GITHUB_OUTPUT" + - name: Build the unsigned .dmg + run: just package-macos ${{ steps.version.outputs.version }} + - name: Attach it to the release + if: github.ref_type == 'tag' + env: + GH_TOKEN: ${{ github.token }} + run: | + v=${{ steps.version.outputs.version }} + gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "wanderbooks $v" + gh release upload "$GITHUB_REF_NAME" --clobber target/packages/$v/*.dmg + - name: Keep it with the run (a dispatch has no release) + uses: actions/upload-artifact@v4 + with: + name: macos-dmg-unsigned + path: target/packages/${{ steps.version.outputs.version }}/*.dmg diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index cdb39b6..94028d7 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -331,7 +331,9 @@ fn run_local(mode: &str, args: Vec) -> i32 { "token" => ledger_host::commands::token(&prog, args), "connect" => ledger_host::commands::connect(&prog, args), "passphrase" => ledger_host::commands::passphrase(&prog, args), - "update" => ledger_host::update::update(&prog, env!("CARGO_PKG_VERSION"), args), + "update" => { + ledger_host::update::update(&prog, ledger_host::update::program_version(), args) + } "rollback" => ledger_host::update::rollback(&prog, args), _ => ledger_host::commands::books(&prog, args), }; @@ -399,6 +401,7 @@ fn is_bare_launch(words: &[String], stdin_is_terminal: bool) -> bool { } fn main() { + ledger_host::update::set_program_version(env!("CARGO_PKG_VERSION")); // A file's path may be any name the system takes: the file commands // get the words as given. Everything else is text. let given: Vec = env::args_os().skip(1).collect(); diff --git a/crates/ledger-cli/tests/update.rs b/crates/ledger-cli/tests/update.rs index 4f121b2..e6a2500 100644 --- a/crates/ledger-cli/tests/update.rs +++ b/crates/ledger-cli/tests/update.rs @@ -4,7 +4,8 @@ //! and host.rs; the P11a tests update_is_atomic, failed_update_rolls_back, //! unsigned_update_refused, bridge_survives_update, //! update_waits_for_a_running_call, package_managed_install_refuses_update, -//! and the rollback laws (kept version, the format gate). +//! and the rollback laws (kept version, the format gate); and step 4's +//! daily check, staged update and apply-at-next-start. #![cfg(target_os = "linux")] mod common; @@ -16,8 +17,7 @@ use std::process::{Command, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; -const KNOBS: &str = - "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300"; +const KNOBS: &str = "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300,update_check=400"; /// The platform key a manifest names, as the program computes it. fn platform() -> String { @@ -76,6 +76,10 @@ fn base_program() -> &'static Path { struct Home { dir: tempfile::TempDir, exe: PathBuf, + /// A pretend AppImage (P11a step 4): the programs run from `exe`, as + /// from an image's mount, and `APPIMAGE` names this other file, the + /// one an update replaces. + image: Option, } impl Home { @@ -95,7 +99,22 @@ impl Home { std::fs::create_dir(&bin).unwrap(); let exe = bin.join("wanderbooks"); std::fs::copy(base_program(), &exe).unwrap(); - let home = Self { dir, exe }; + let home = Self { + dir, + exe, + image: None, + }; + home.runnable(); + home + } + + /// A home whose program is an AppImage: the same program twice, one + /// copy to run (the mount's) and one the image file `APPIMAGE` names. + fn new_image(tag: &str) -> Self { + let mut home = Self::new(tag); + let image = home.dir.path().join("Wanderbooks.AppImage"); + std::fs::copy(base_program(), &image).unwrap(); + home.image = Some(image); home.runnable(); home } @@ -103,25 +122,35 @@ impl Home { /// Wait until the copy can be run (a child another test thread forked /// while it was written may hold it open for a moment). fn runnable(&self) { - let deadline = Instant::now() + Duration::from_secs(10); - loop { - match Command::new(&self.exe) - .arg("--help") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - { - Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => { - assert!(Instant::now() < deadline, "the copy stayed busy"); - std::thread::sleep(Duration::from_millis(20)); + for exe in std::iter::once(&self.exe).chain(self.image.as_ref()) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + match Command::new(exe) + .arg("--help") + .env("APPIMAGE_EXTRACT_AND_RUN", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + { + Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => { + assert!(Instant::now() < deadline, "the copy stayed busy"); + std::thread::sleep(Duration::from_millis(20)); + } + Err(e) => panic!("the copy can't be run: {e}"), + Ok(_) => break, } - Err(e) => panic!("the copy can't be run: {e}"), - Ok(_) => return, } } } + /// The environment of the pretend AppImage, if this is one. + fn image_env(&self, c: &mut Command) { + if let Some(image) = &self.image { + c.env("APPIMAGE", image); + } + } + fn root(&self) -> PathBuf { self.dir.path().join(".local/share/wanderbooks") } @@ -139,11 +168,13 @@ impl Home { .env_clear() .env("PATH", "/usr/bin:/bin") .env("HOME", self.dir.path()); + self.image_env(&mut c); c } + /// The program that updates replace: the image, if there is one. fn program(&self) -> Vec { - std::fs::read(&self.exe).unwrap() + std::fs::read(self.image.as_ref().unwrap_or(&self.exe)).unwrap() } fn create(&self, id: &str) { @@ -180,6 +211,7 @@ impl Home { .env("PATH", "/usr/bin:/bin") .env("HOME", self.dir.path()) .stdin(Stdio::null()); + self.image_env(&mut c); c.output().unwrap() } @@ -655,7 +687,11 @@ impl Drop for Agent { /// host and server run the new file afterwards, and the head is the one /// the writes made. fn update_under_writes(tag: &str) -> (Home, Agent, Vec) { - let home = Home::new(tag); + update_under_writes_in(Home::new(tag)) +} + +fn update_under_writes_in(home: Home) -> (Home, Agent, Vec) { + let tag = "under-writes"; home.create("acme"); let release = Release::new(); let next = newer(&home, tag); @@ -673,18 +709,21 @@ fn update_under_writes(tag: &str) -> (Home, Agent, Vec) { release.dir.path().join("manifest.json").display() ); let key = release.dir.path().join("release.pub"); + let image = home.image.clone(); std::thread::spawn(move || { let _keep = &release; - Command::new(exe) - .args(["update", "--root"]) + let mut c = Command::new(exe); + c.args(["update", "--root"]) .arg(root) .args(["--feed", &feed, "--trusted-key"]) .arg(key) .env_clear() .env("PATH", "/usr/bin:/bin") - .env("HOME", dir) - .output() - .unwrap() + .env("HOME", dir); + if let Some(image) = image { + c.env("APPIMAGE", image); + } + c.output().unwrap() }) }; let mut written = vec!["4000".to_string()]; @@ -742,6 +781,39 @@ fn bridge_survives_update() { assert!(in_books >= written.len(), "{in_books} < {}", written.len()); } +#[test] +fn a_replaced_appimage_restarts_the_books() { + // The programs run from one file (an image's mount) while `APPIMAGE` + // names another (the image). Replacing the image alone must restart + // the host onto it, with the session and every write intact. + let home = Home::new_image("appimage"); + let image = home.image.clone().unwrap(); + home.create("acme"); + let release = Release::new(); + let next = newer(&home, "appimage"); + release.publish("9.0.0", &next, |_| {}); + let mut agent = Agent::start(&home, "acme"); + agent.add_account("4000").unwrap(); + let host_before = wait_host(&home); + let out = home.run("update", Some(&release)); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(std::fs::read(&image).unwrap(), next); + // The mount's copy is untouched; only the image changed. + assert_ne!(std::fs::read(&home.exe).unwrap(), next); + assert!( + wait_for(60, || { + home.pids("host").len() == 1 && home.pids("host")[0] != host_before + }), + "the host never restarted onto the replaced image" + ); + let host = wait_host(&home); + assert_eq!( + std::fs::read_link(format!("/proc/{host}/exe")).unwrap(), + image + ); + assert!(agent.add_account("4001").is_ok(), "the session carries on"); +} + #[test] fn update_waits_for_a_running_call() { // No call is cut off or made twice: each account written across the @@ -763,3 +835,161 @@ fn update_waits_for_a_running_call() { "a write lost, or made twice, across the update" ); } + +/// Name `release` as this home's local feed, as the owner would +/// (`/updates/feed`: the address, then the key file). +fn name_feed(home: &Home, release: &Release) { + std::fs::create_dir_all(home.updates()).unwrap(); + std::fs::write( + home.updates().join("feed"), + format!( + "file://{}\n{}\n", + release.dir.path().join("manifest.json").display(), + release.dir.path().join("release.pub").display() + ), + ) + .unwrap(); +} + +fn staged_version(home: &Home) -> Option { + let meta = std::fs::read(home.updates().join("staged/meta.json")).ok()?; + let v: Value = serde_json::from_slice(&meta).ok()?; + v["version"].as_str().map(str::to_string) +} + +#[test] +fn the_host_stages_a_daily_update_and_the_next_start_applies_it() { + let home = Home::new("daily"); + home.create("acme"); + let old = home.program(); + let release = Release::new(); + let next = newer(&home, "daily"); + release.publish("9.0.0", &next, |_| {}); + name_feed(&home, &release); + let mut agent = Agent::start(&home, "acme"); + agent.add_account("4000").unwrap(); + let host = wait_host(&home); + + // The host looks, downloads, verifies and stages; nothing running + // changes: not the file, not the host. + assert!( + wait_for(30, || staged_version(&home).as_deref() == Some("9.0.0")), + "nothing was staged" + ); + assert_eq!(home.program(), old, "the program was swapped while open"); + assert!(!home.updates().join("previous").exists()); + std::thread::sleep(Duration::from_millis(1200)); + assert_eq!(home.pids("host"), vec![host], "the host restarted"); + assert!(agent.add_account("4001").is_ok()); + + // The books close; the next opener applies it before the host starts. + drop(agent); + let out = home.cmd("close").output().unwrap(); + assert!(out.status.success(), "{}", text(&out)); + assert!(wait_for(20, || home.pids("host").is_empty())); + let mut agent = Agent::start(&home, "acme"); + assert_eq!(home.program(), next, "the staged update wasn't applied"); + assert!(!home.updates().join("staged").exists()); + assert!( + home.updates() + .join("previous") + .join("wanderbooks") + .is_file() + ); + assert_eq!( + std::fs::read(home.updates().join("previous/wanderbooks")).unwrap(), + old + ); + let host = wait_host(&home); + assert!( + !std::fs::read_link(format!("/proc/{host}/exe")) + .unwrap() + .to_string_lossy() + .contains("(deleted)") + ); + let chart = agent.call("books.chart", json!({})).unwrap(); + let codes: Vec<&str> = chart["accounts"] + .as_array() + .unwrap() + .iter() + .filter_map(|a| a["code"].as_str()) + .collect(); + assert!(codes.contains(&"4000") && codes.contains(&"4001")); +} + +#[test] +fn a_daily_update_that_is_not_ours_is_not_staged() { + let home = Home::new("daily-bad"); + home.create("acme"); + let release = Release::new(); + let next = newer(&home, "daily-bad"); + let other = minisign::KeyPair::generate_unencrypted_keypair().unwrap(); + // Signed by another key; and, separately, a wrong checksum. + let sig = Release::b64(&Release::sign_with(&other, &next)); + release.publish("9.0.0", &next, |e| e["signature"] = json!(sig)); + name_feed(&home, &release); + let _agent = Agent::start(&home, "acme"); + assert!( + wait_for(30, || home.updates().join("checked").exists()), + "no look" + ); + std::thread::sleep(Duration::from_millis(500)); + assert_eq!(staged_version(&home), None); + assert!(!home.updates().join("staged").exists()); + release.publish("9.0.1", &next, |e| e["sha256"] = json!("00".repeat(32))); + std::fs::remove_file(home.updates().join("checked")).unwrap(); + assert!( + wait_for(30, || home.updates().join("checked").exists()), + "no second look" + ); + std::thread::sleep(Duration::from_millis(500)); + assert_eq!(staged_version(&home), None); +} + +#[test] +fn update_installs_the_copy_the_host_staged_without_downloading_again() { + let home = Home::new("daily-use"); + home.create("acme"); + let release = Release::new(); + let next = newer(&home, "daily-use"); + release.publish("9.0.0", &next, |_| {}); + name_feed(&home, &release); + let _agent = Agent::start(&home, "acme"); + assert!(wait_for(30, || staged_version(&home).is_some())); + // The program file is gone from the feed: only the staged copy is left. + std::fs::remove_file(release.dir.path().join("wanderbooks-9.0.0")).unwrap(); + let out = home.run("update", None); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(home.program(), next); + assert!(!home.updates().join("staged").exists()); +} + +#[test] +fn a_staged_update_that_was_tampered_with_is_dropped_not_applied() { + let home = Home::new("daily-tamper"); + home.create("acme"); + let old = home.program(); + let release = Release::new(); + let next = newer(&home, "daily-tamper"); + release.publish("9.0.0", &next, |_| {}); + name_feed(&home, &release); + let agent = Agent::start(&home, "acme"); + assert!(wait_for(30, || staged_version(&home).is_some())); + drop(agent); + let out = home.cmd("close").output().unwrap(); + assert!(out.status.success(), "{}", text(&out)); + assert!(wait_for(20, || home.pids("host").is_empty())); + // The staged file changes, its recorded checksum with it: the + // signature no longer holds. + let staged = home.updates().join("staged"); + let mut bytes = std::fs::read(staged.join("wanderbooks")).unwrap(); + bytes.extend_from_slice(b"tamper"); + std::fs::write(staged.join("wanderbooks"), &bytes).unwrap(); + let mut meta: Value = + serde_json::from_slice(&std::fs::read(staged.join("meta.json")).unwrap()).unwrap(); + meta["sha256"] = json!(sha256(&bytes)); + std::fs::write(staged.join("meta.json"), meta.to_string()).unwrap(); + let _agent = Agent::start(&home, "acme"); + assert_eq!(home.program(), old, "a tampered update was applied"); + assert!(!staged.exists(), "and kept"); +} diff --git a/crates/ledger-host/src/host.rs b/crates/ledger-host/src/host.rs index 7921311..bc99696 100644 --- a/crates/ledger-host/src/host.rs +++ b/crates/ledger-host/src/host.rs @@ -196,28 +196,46 @@ fn children_executable() -> PathBuf { } } +/// A program file's identity: device and inode, size and last write. A +/// replaced file differs in the inode, and a rewritten one in the rest. +type Identity = (u64, u64, u64, i64, i64); + +fn identity_from(meta: &std::fs::Metadata) -> Identity { + ( + meta.dev(), + meta.ino(), + meta.len(), + meta.mtime(), + meta.mtime_nsec(), + ) +} + /// The running executable's file identity, and its file's now: they -/// differ once the file was replaced (INVARIANT 7). -fn running_identity() -> Option<(u64, u64)> { +/// differ once the file was replaced (INVARIANT 7). An AppImage is its +/// image file (`APPIMAGE`), not the program inside the mount. +fn running_identity() -> Option { + if let Some(image) = crate::start::own_appimage(std::env::var_os("APPIMAGE").as_deref()) { + return Some(identity_from(&std::fs::metadata(image).ok()?)); + } let running = if cfg!(target_os = "linux") { std::fs::metadata("/proc/self/exe").ok()? } else { std::fs::metadata(std::env::current_exe().ok()?).ok()? }; - Some((running.dev(), running.ino())) + Some(identity_from(&running)) } /// The identity of the file at this executable's path, if it is a /// program (a file half-written or gone doesn't count yet). -fn file_identity() -> Option<(u64, u64)> { +fn file_identity() -> Option { identity_of(&crate::start::this_executable().ok()?) } -/// The identity (device, inode) of the program at `path`: a file someone -/// may run; else None. -fn identity_of(path: &Path) -> Option<(u64, u64)> { +/// The identity of the program at `path`: a file someone may run; else +/// None. +fn identity_of(path: &Path) -> Option { let meta = std::fs::metadata(path).ok()?; - (meta.is_file() && meta.permissions().mode() & 0o111 != 0).then(|| (meta.dev(), meta.ino())) + (meta.is_file() && meta.permissions().mode() & 0o111 != 0).then(|| identity_from(&meta)) } /// Wait at most `wait` for a host to answer on the lease socket: whether @@ -482,6 +500,12 @@ impl Host { let started_as = running_identity(); let mut exe_poll = tokio::time::interval(self.knobs.exe_poll); exe_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + // The daily look for a newer release (P11a step 4): the work is + // a thread of its own, one at a time, and only stages a file. + let mut update_poll = + tokio::time::interval((self.knobs.update_check / 4).max(Duration::from_millis(10))); + update_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let looking = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let mut idle_at = Some(Instant::now() + self.knobs.close_after); let server_start = tokio::time::sleep(self.knobs.server_delay); tokio::pin!(server_start); @@ -639,6 +663,20 @@ impl Host { } } } + _ = update_poll.tick(), if matches!(self.phase, Phase::Open) => { + use std::sync::atomic::Ordering; + if !looking.swap(true, Ordering::SeqCst) { + let (root, every, looking) = (self.root.clone(), self.knobs.update_check, looking.clone()); + std::thread::spawn(move || { + match crate::update::stage_if_due(&root, crate::update::program_version(), every) { + Ok(Some(version)) => eprintln!("wanderbooks host: version {version} is downloaded and checked; it starts the next time the books open, or at `wanderbooks update`"), + Ok(None) => {} + Err(why) => eprintln!("wanderbooks host: the look for a newer version failed: {why}"), + } + looking.store(false, Ordering::SeqCst); + }); + } + } _ = exe_poll.tick(), if matches!(self.phase, Phase::Open) => { if started_as.is_some() && file_identity().is_some_and(|now| Some(now) != started_as) { eprintln!("wanderbooks host: a new version is installed: restarting"); diff --git a/crates/ledger-host/src/knobs.rs b/crates/ledger-host/src/knobs.rs index 04f24ad..bd7dd0c 100644 --- a/crates/ledger-host/src/knobs.rs +++ b/crates/ledger-host/src/knobs.rs @@ -33,6 +33,9 @@ pub struct Knobs { /// How long after books open the server first looks for a drill due /// (60 s), passed to the server as `--drill-first`. pub drill_first: Duration, + /// How long between the host's looks for a newer release (plan + /// P11a step 4: a day). + pub update_check: Duration, } impl Default for Knobs { @@ -47,6 +50,7 @@ impl Default for Knobs { open_wait: Duration::from_secs(120), backup_quiet: Duration::from_secs(30), drill_first: Duration::from_secs(60), + update_check: Duration::from_secs(24 * 60 * 60), } } } @@ -76,6 +80,7 @@ impl Knobs { "open_wait" => k.open_wait = d, "backup_quiet" => k.backup_quiet = d, "drill_first" => k.drill_first = d, + "update_check" => k.update_check = d, other => return Err(format!("{FLAG}: there is no knob {other:?}")), } } @@ -97,6 +102,7 @@ impl Knobs { ("open_wait", self.open_wait, d.open_wait), ("backup_quiet", self.backup_quiet, d.backup_quiet), ("drill_first", self.drill_first, d.drill_first), + ("update_check", self.update_check, d.update_check), ] { if now != default { parts.push(format!("{name}={}", now.as_millis())); @@ -130,6 +136,7 @@ mod tests { assert_eq!(k.open_wait, Duration::from_secs(120)); assert_eq!(k.backup_quiet, Duration::from_secs(30)); assert_eq!(k.drill_first, Duration::from_secs(60)); + assert_eq!(k.update_check, Duration::from_secs(86_400)); assert_eq!(k.to_arg(), None); assert!(k.args().is_empty()); assert_eq!(Knobs::parse("").unwrap(), k); @@ -138,7 +145,7 @@ mod tests { #[test] fn knobs_pass_on_exactly() { let k = Knobs::parse( - "close_after=500,backoff=20,server_delay=3000,init_wait=100,queue_wait=900,exe_poll=50,open_wait=70,backup_quiet=40,drill_first=50", + "close_after=500,backoff=20,server_delay=3000,init_wait=100,queue_wait=900,exe_poll=50,open_wait=70,backup_quiet=40,drill_first=50,update_check=60", ) .unwrap(); assert_eq!(k.close_after, Duration::from_millis(500)); @@ -150,6 +157,7 @@ mod tests { assert_eq!(k.open_wait, Duration::from_millis(70)); assert_eq!(k.backup_quiet, Duration::from_millis(40)); assert_eq!(k.drill_first, Duration::from_millis(50)); + assert_eq!(k.update_check, Duration::from_millis(60)); let args = k.args(); assert_eq!(args[0], FLAG); assert_eq!(Knobs::parse(&args[1]).unwrap(), k); diff --git a/crates/ledger-host/src/start.rs b/crates/ledger-host/src/start.rs index 47f7bae..4409599 100644 --- a/crates/ledger-host/src/start.rs +++ b/crates/ledger-host/src/start.rs @@ -13,7 +13,8 @@ //! it names a socket this account owns (the owner's ssh agent, so the //! server's backups reach a private git repository, D-30). Nothing //! else passes (a scope's own identifiers, such as INVOCATION_ID, are -//! systemd's). +//! systemd's). One more: APPIMAGE, only when it names an absolute +//! file, so the host knows its own AppImage (below). //! 2. Its standard input and output are null, its errors go to //! run/host.log (0600); it starts a new session with umask 077. Every //! other descriptor is closed (std opens them close-on-exec, and the @@ -23,7 +24,11 @@ //! scope couldn't be made (systemd-run failed without the host ever //! writing its first line), or there is no such scope, directly. A //! host that ran and stopped is never started a second time. -//! 4. It runs this executable's file as it is on disk now. +//! 4. It runs this executable's file as it is on disk now; for an +//! AppImage that is the image file (APPIMAGE), not the program inside +//! its mount, which goes away with the image it came from. The host +//! reads that path about itself and never tells it to an agent (I-2), +//! so a replaced image restarts the books like any replaced program. //! 5. The opener doesn't wait for it, and reaps it when it ends. //! ``` @@ -49,6 +54,9 @@ const LOG_LIMIT: u64 = 8 * 1024 * 1024; /// replaced file reads as "… (deleted)"; the path, not the old file, is /// what a new host runs. pub fn this_executable() -> Result { + if let Some(image) = own_appimage(std::env::var_os("APPIMAGE").as_deref()) { + return Ok(PathBuf::from(image)); + } let exe = std::env::current_exe().map_err(|e| format!("can't find this program: {e}"))?; let text = exe.to_string_lossy(); if let Some(path) = text.strip_suffix(" (deleted)") @@ -59,6 +67,13 @@ pub fn this_executable() -> Result { Ok(exe) } +/// 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 { + let path = Path::new(value?); + (path.is_absolute() && path.is_file()).then(|| path.as_os_str().to_os_string()) +} + /// This account's user ID. fn own_uid() -> u32 { // SAFETY: geteuid has no preconditions and can't fail. @@ -128,7 +143,11 @@ fn own_agent(named: Option<&std::ffi::OsStr>) -> Option { /// The host's environment (INVARIANT 1), given the opener's /// `SSH_AUTH_SOCK`. -fn host_env(home: &Path, ssh_agent: Option<&std::ffi::OsStr>) -> Vec<(String, std::ffi::OsString)> { +fn host_env( + home: &Path, + ssh_agent: Option<&std::ffi::OsStr>, + appimage: Option<&std::ffi::OsStr>, +) -> Vec<(String, std::ffi::OsString)> { let mut env = vec![ ("PATH".to_string(), PATH.into()), ("HOME".to_string(), home.as_os_str().to_os_string()), @@ -142,6 +161,9 @@ fn host_env(home: &Path, ssh_agent: Option<&std::ffi::OsStr>) -> Vec<(String, st if let Some(agent) = own_agent(ssh_agent) { env.push(("SSH_AUTH_SOCK".into(), agent)); } + if let Some(image) = own_appimage(appimage) { + env.push(("APPIMAGE".into(), image)); + } env } @@ -183,6 +205,10 @@ pub fn start_host( let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?; let run = found.run_dir().map_err(|e| e.to_string())?; let home = ledger_store::root::account_home().map_err(|e| e.to_string())?; + // A staged update is applied here and nowhere while books are open: + // no host runs now, so no call is cut off (update.rs INVARIANT 9). A + // failure leaves the program as it was. + let _ = crate::update::apply_staged(&found, crate::update::program_version()); let exe = this_executable()?; let mut args: Vec = vec!["host".into()]; // An explicit root goes on as found (absolute): the host starts in /. @@ -227,6 +253,7 @@ pub fn start_host( .envs(host_env( &home, std::env::var_os("SSH_AUTH_SOCK").as_deref(), + std::env::var_os("APPIMAGE").as_deref(), )) .current_dir("/") .stdin(Stdio::null()) @@ -385,7 +412,7 @@ mod tests { #[test] fn the_host_environment_carries_nothing_else() { - let env = host_env(Path::new("/home/someone"), None); + let env = host_env(Path::new("/home/someone"), None, None); let names: Vec<&str> = env.iter().map(|(n, _)| n.as_str()).collect(); assert_eq!(&names[..2], ["PATH", "HOME"]); for n in &names[2..] { @@ -393,7 +420,8 @@ mod tests { [ "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", - "SSH_AUTH_SOCK" + "SSH_AUTH_SOCK", + "APPIMAGE" ] .contains(n), "{n}" @@ -403,6 +431,22 @@ mod tests { assert_eq!(env[1].1, "/home/someone"); } + #[test] + fn only_an_absolute_file_passes_as_the_appimage() { + let dir = tempfile::tempdir().unwrap(); + let image = dir.path().join("W.AppImage"); + std::fs::write(&image, b"x").unwrap(); + let home = Path::new("/home/someone"); + let has = |v: Option<&std::ffi::OsStr>| { + host_env(home, None, v).iter().any(|(n, _)| n == "APPIMAGE") + }; + assert!(has(Some(image.as_os_str()))); + assert!(!has(None)); + assert!(!has(Some("W.AppImage".as_ref())), "relative"); + assert!(!has(Some(dir.path().as_os_str())), "a folder"); + assert!(!has(Some(dir.path().join("gone").as_os_str()))); + } + #[test] fn only_an_own_ssh_agent_socket_passes() { let dir = tempfile::tempdir().unwrap(); @@ -413,7 +457,7 @@ mod tests { Some(sock.clone().into_os_string()) ); // It reaches the host's environment, once. - let env = host_env(Path::new("/home/someone"), Some(sock.as_os_str())); + let env = host_env(Path::new("/home/someone"), Some(sock.as_os_str()), None); let agents: Vec<_> = env.iter().filter(|(n, _)| n == "SSH_AUTH_SOCK").collect(); assert_eq!(agents.len(), 1); assert_eq!(agents[0].1, sock.as_os_str()); @@ -422,7 +466,7 @@ mod tests { let link = dir.path().join("link.sock"); std::os::unix::fs::symlink(&sock, &link).unwrap(); assert_eq!(own_agent(Some(link.as_os_str())), None); - let env = host_env(Path::new("/home/someone"), Some(link.as_os_str())); + let env = host_env(Path::new("/home/someone"), Some(link.as_os_str()), None); assert!(env.iter().all(|(n, _)| n != "SSH_AUTH_SOCK")); // Named relative to here: not taken, even where it leads to one. let here = std::env::current_dir().unwrap(); diff --git a/crates/ledger-host/src/update.rs b/crates/ledger-host/src/update.rs index b1f2266..6d2bf37 100644 --- a/crates/ledger-host/src/update.rs +++ b/crates/ledger-host/src/update.rs @@ -36,7 +36,17 @@ //! newer one: the only way back is a restore, plan 3.9). //! 7. A package-managed install is never touched. //! 8. A program with no release key compiled in updates from nothing -//! but a local (`file://`) feed whose key the owner names. +//! but a local (`file://`) feed whose key the owner names: on the +//! command line, or in `/updates/feed` (the feed's address on +//! the first line, the key file's path on the second). +//! 9. The host looks for a newer release at most once a day while the +//! books are open, and only downloads and verifies it (the same +//! checks as INVARIANT 1) into `/updates/staged/`. It never +//! swaps: a staged update is applied only (a) by `wanderbooks +//! update`, the owner's say-so, or (b) by an opener just before it +//! starts a host, when no host is running, so no call is ever cut +//! off. A staged file is checked again against the key before it is +//! applied; one that fails, or does not start, is dropped. //! SCENARIOS: //! 1. IF the feed offers a higher version, signed by our key: SHOULD //! replace the program and keep the old one. @@ -44,6 +54,10 @@ //! is by another key or missing: SHOULD refuse, changing nothing. //! 3. IF the new program can't start: SHOULD put the old one back. //! 4. IF a book consented to a newer format: SHOULD refuse to roll back. +//! 5. IF the host's daily look finds a signed newer release: SHOULD +//! stage it and change nothing running; the next host start runs it. +//! 6. IF the daily look finds a bad signature or checksum: SHOULD stage +//! nothing. //! ``` use serde::Deserialize; @@ -72,6 +86,26 @@ const UPDATES: &str = "updates"; const PREVIOUS: &str = "previous"; const PROGRAM: &str = "wanderbooks"; const META: &str = "meta.json"; +const STAGED: &str = "staged"; +const FEED_FILE: &str = "feed"; +const CHECKED: &str = "checked"; + +/// The version of the program being run, set by its `main` (this crate's +/// own is the fallback: the packages rewrite only the program's). +static PROGRAM_VERSION: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); + +/// Say which version this program is. +pub fn set_program_version(version: &'static str) { + let _ = PROGRAM_VERSION.set(version); +} + +/// This program's version. +pub fn program_version() -> &'static str { + PROGRAM_VERSION + .get() + .copied() + .unwrap_or(env!("CARGO_PKG_VERSION")) +} const MAX_MANIFEST: usize = 1 << 20; const MAX_PROGRAM: u64 = 1 << 30; @@ -244,11 +278,18 @@ pub fn update( Install::SelfUpdating(target) => target, }; let current_v = parse_version(current).ok_or("this program's version isn't one of ours")?; - let feed = flags.feed.as_deref().unwrap_or(DEFAULT_FEED); - let key = trusted_key(feed, flags.key_file.as_deref())?; - let manifest_bytes = fetch(feed, MAX_MANIFEST as u64)?; - let manifest: Manifest = serde_json::from_slice(&manifest_bytes) - .map_err(|e| format!("the release manifest isn't one we can read: {e}"))?; + let found_root = ledger_store::root::Root::find(flags.root.as_deref()).ok(); + let local = found_root + .as_ref() + .and_then(|r| local_feed(&r.path().join(UPDATES))); + let (feed, key_file) = match (&flags.feed, &local) { + (Some(feed), _) => (feed.clone(), flags.key_file.clone()), + (None, Some((feed, key))) => (feed.clone(), Some(key.clone())), + (None, None) => (DEFAULT_FEED.to_string(), flags.key_file.clone()), + }; + let feed = feed.as_str(); + let key = trusted_key(feed, key_file.as_deref())?; + let manifest = fetch_manifest(feed)?; let offered = parse_version(&manifest.version).ok_or_else(|| { format!( "the manifest's version {:?} isn't one of ours", @@ -272,9 +313,14 @@ pub fn update( .get(&platform) .ok_or_else(|| format!("release {} has no build for {platform}", manifest.version))?; if flags.check { + let ready = found_root + .as_ref() + .and_then(|r| staged_bytes(&r.path().join(UPDATES), &manifest.version)) + .is_some(); println!( - "wanderbooks {} is available (this is {current}). Run `{}` to install it.", + "wanderbooks {} is available{} (this is {current}). Run `{}` to install it.", manifest.version, + if ready { ", already downloaded" } else { "" }, prog.trim_end_matches(" --check") ); if !manifest.notes.trim().is_empty() { @@ -284,9 +330,14 @@ pub fn update( } let root = ledger_store::root::Root::find(flags.root.as_deref()).map_err(|e| e.to_string())?; let updates = updates_dir(&root)?; - let _lock = take_lock(&updates)?; + let _lock = take_lock_within(&updates, Duration::from_secs(10))?; - let bytes = fetch(&resolve(feed, &entry.url)?, MAX_PROGRAM)?; + // A copy the host already downloaded and verified is used as it is + // found (and verified again below); else the download. + let bytes = match staged_bytes(&updates, &manifest.version) { + Some(bytes) => bytes, + None => fetch(&resolve(feed, &entry.url)?, MAX_PROGRAM)?, + }; verify(&bytes, entry, &key)?; // Verified: from here the files are ours to write. Staged, then the @@ -313,6 +364,7 @@ pub fn update( .into()); } let _ = std::fs::remove_dir_all(&staged_dir); + let _ = std::fs::remove_dir_all(updates.join(STAGED)); println!( "Updated wanderbooks from {current} to {}.", manifest.version @@ -323,6 +375,182 @@ pub fn update( Ok(()) } +/// The manifest at `feed`. +fn fetch_manifest(feed: &str) -> Result { + let bytes = fetch(feed, MAX_MANIFEST as u64)?; + serde_json::from_slice(&bytes) + .map_err(|e| format!("the release manifest isn't one we can read: {e}").into()) +} + +/// The feed the owner named for this root (INVARIANT 8): a `file://` +/// address, and the file of the key to check it against. +fn local_feed(updates: &Path) -> Option<(String, PathBuf)> { + let text = std::fs::read_to_string(updates.join(FEED_FILE)).ok()?; + let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); + let feed = lines.next()?; + let key = lines.next()?; + feed.starts_with("file://") + .then(|| (feed.to_string(), PathBuf::from(key))) +} + +/// The staged program, if it is the one for `version` and whole. +fn staged_bytes(updates: &Path, version: &str) -> Option> { + let dir = updates.join(STAGED); + let meta: serde_json::Value = + serde_json::from_slice(&std::fs::read(dir.join(META)).ok()?).ok()?; + if meta["version"].as_str() != Some(version) { + return None; + } + let bytes = std::fs::read(dir.join(PROGRAM)).ok()?; + sha256_hex(&bytes) + .eq_ignore_ascii_case(meta["sha256"].as_str()?) + .then_some(bytes) +} + +/// The version staged, if any. +pub fn staged_version(root: &ledger_store::root::Root) -> Option { + let meta = std::fs::read(root.path().join(UPDATES).join(STAGED).join(META)).ok()?; + let meta: serde_json::Value = serde_json::from_slice(&meta).ok()?; + meta["version"].as_str().map(str::to_string) +} + +/// INVARIANT 9, the host's daily look: stage a newer release if there is +/// one and `every` has passed since the last look. Returns the version +/// newly staged. Changes nothing that runs. +pub fn stage_if_due( + root: &ledger_store::root::Root, + current: &str, + every: Duration, +) -> Result, Failure> { + let Install::SelfUpdating(_) = this_install()? else { + return Ok(None); + }; + let updates = updates_dir(root)?; + let checked = updates.join(CHECKED); + if std::fs::metadata(&checked) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.elapsed().ok()) + .is_some_and(|age| age < every) + { + return Ok(None); + } + // Marked first: a failed look waits a day too. + std::fs::write(&checked, b"").map_err(|e| format!("can't note the look: {}", e.kind()))?; + let current_v = parse_version(current).ok_or("this program's version isn't one of ours")?; + let (feed, key_file) = match local_feed(&updates) { + Some((feed, key)) => (feed, Some(key)), + None => (DEFAULT_FEED.to_string(), None), + }; + let key = trusted_key(&feed, key_file.as_deref())?; + let Ok(_lock) = take_lock(&updates) else { + return Ok(None); + }; + let manifest = fetch_manifest(&feed)?; + let offered = + parse_version(&manifest.version).ok_or("the manifest's version isn't one of ours")?; + if offered <= current_v { + let _ = std::fs::remove_dir_all(updates.join(STAGED)); + return Ok(None); + } + if staged_bytes(&updates, &manifest.version).is_some() { + return Ok(None); + } + let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH); + let entry = manifest + .platforms + .get(&platform) + .ok_or_else(|| format!("release {} has no build for {platform}", manifest.version))?; + let bytes = fetch(&resolve(&feed, &entry.url)?, MAX_PROGRAM)?; + verify(&bytes, entry, &key)?; + let dir = updates.join(STAGED); + let _ = std::fs::remove_dir_all(&dir); + private_dir(&dir)?; + write_program(&dir.join(PROGRAM), &bytes)?; + let meta = serde_json::json!({ + "version": manifest.version, + "sha256": sha256_hex(&bytes), + "signature": entry.signature, + "notes": manifest.notes, + }); + std::fs::write(dir.join(META), meta.to_string()) + .map_err(|e| format!("can't stage the update: {}", e.kind()))?; + Ok(Some(manifest.version)) +} + +/// INVARIANT 9, applying: swap in the staged program, if there is one +/// for a newer version. The caller guarantees no host is running (it is +/// about to start one) or is the owner running `update`. Returns the +/// version applied. +pub fn apply_staged( + root: &ledger_store::root::Root, + current: &str, +) -> Result, Failure> { + let updates = root.path().join(UPDATES); + let dir = updates.join(STAGED); + if !dir.join(META).is_file() { + return Ok(None); + } + let Install::SelfUpdating(target) = this_install()? else { + return Ok(None); + }; + let Ok(_lock) = take_lock(&updates) else { + return Ok(None); + }; + let meta: serde_json::Value = std::fs::read(dir.join(META)) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + let drop_staged = || { + let _ = std::fs::remove_dir_all(&dir); + }; + let (Some(version), Some(sha256), Some(signature)) = ( + meta["version"].as_str(), + meta["sha256"].as_str(), + meta["signature"].as_str(), + ) else { + drop_staged(); + return Ok(None); + }; + let current_v = parse_version(current).ok_or("this program's version isn't one of ours")?; + if parse_version(version).is_none_or(|v| v <= current_v) { + drop_staged(); + return Ok(None); + } + let local = local_feed(&updates); + let key = trusted_key( + local.as_ref().map_or(DEFAULT_FEED, |(f, _)| f.as_str()), + local.as_ref().map(|(_, k)| k.as_path()), + )?; + let Some(bytes) = staged_bytes(&updates, version) else { + drop_staged(); + return Err("the staged update is damaged: dropped".into()); + }; + let entry = Entry { + url: String::new(), + sha256: sha256.to_string(), + signature: signature.to_string(), + }; + if let Err(e) = verify(&bytes, &entry, &key) { + drop_staged(); + return Err(e); + } + drop(bytes); + keep_previous(&updates, &target, current)?; + swap_in(&target, &dir.join(PROGRAM))?; + if let Err(why) = healthy(&target) { + let back = swap_in(&target, &updates.join(PREVIOUS).join(PROGRAM)); + drop_staged(); + return Err(match back { + Ok(()) => format!("the staged version {version} didn't start ({why}); the old one is back"), + Err(e) => format!("the staged version {version} didn't start ({why}) and the old one couldn't be put back ({e})"), + } + .into()); + } + drop_staged(); + Ok(Some(version.to_string())) +} + /// `wanderbooks rollback`: go back to the version the last update /// replaced. pub fn rollback(prog: &str, args: impl IntoIterator) -> Result<(), Failure> { @@ -337,7 +565,7 @@ pub fn rollback(prog: &str, args: impl IntoIterator) -> Result<() }; let root = ledger_store::root::Root::find(flags.root.as_deref()).map_err(|e| e.to_string())?; let updates = updates_dir(&root)?; - let _lock = take_lock(&updates)?; + let _lock = take_lock_within(&updates, Duration::from_secs(10))?; let previous = updates.join(PREVIOUS); let meta: serde_json::Value = std::fs::read(previous.join(META)) .ok() @@ -562,6 +790,24 @@ fn private_dir(dir: &Path) -> Result<(), Failure> { /// One update or rollback at a time. fn take_lock(updates: &Path) -> Result { + take_lock_within(updates, Duration::ZERO) +} + +/// The same, waiting up to `wait` for another to finish (the host's +/// look at the feed is a short holder). +fn take_lock_within(updates: &Path, wait: Duration) -> Result { + let deadline = Instant::now() + wait; + loop { + match take_lock_once(updates) { + Err(e) if Instant::now() < deadline && e.to_string().contains("running") => { + std::thread::sleep(Duration::from_millis(50)); + } + other => return other, + } + } +} + +fn take_lock_once(updates: &Path) -> Result { let file = std::fs::OpenOptions::new() .create(true) .append(true) diff --git a/deploy/packaging/Info.plist b/deploy/packaging/Info.plist new file mode 100644 index 0000000..9b274fd --- /dev/null +++ b/deploy/packaging/Info.plist @@ -0,0 +1,12 @@ + + + + + + LSMultipleInstancesProhibited + + NSSupportsAutomaticGraphicsSwitching + + + diff --git a/deploy/packaging/build-macos.sh b/deploy/packaging/build-macos.sh new file mode 100755 index 0000000..f9e809f --- /dev/null +++ b/deploy/packaging/build-macos.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# The macOS bundle (P11a step 6), UNSIGNED, from the release program +# already built at target/release/wanderbooks (`cargo build --release -p +# ledger-cli`). Run on a Mac from the repository root: +# +# build-macos.sh [VERSION] +# +# Puts Wanderbooks_VERSION_.dmg into target/packages/VERSION/. It +# is not signed or notarized (step 5): Gatekeeper refuses it on a Mac +# that downloaded it, and the owner opens it with right-click > Open. +# Portable shell: macOS has BSD sed and no GNU mktemp options. +set -euo pipefail +own=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ledger-cli/Cargo.toml | head -n1) +version=${1:-$own} +out="target/packages/$version" +program=${WB_PROGRAM:-target/release/wanderbooks} +test -x "$program" || { echo "build the release program first: cargo build --release -p ledger-cli" >&2; exit 1; } +rm -rf "$out"; mkdir -p "$out" +stage="target/packages/.stage-$version" +rm -rf "$stage"; mkdir -p "$stage" +cp "$program" "$stage/wanderbooks" +config="$stage/packager.toml" +# cargo-packager reads paths relative to the config file, which is in the +# stage folder here, so they are made absolute. +sed -e "s/^version = \".*\"/version = \"$version\"/" \ + -e "s#^out-dir = .*#out-dir = \"$PWD/$out\"#" \ + -e "s#\"target/release\"#\"$PWD/$stage\"#" \ + -e "s#\"deploy/packaging/#\"$PWD/deploy/packaging/#" \ + -e "s#\"LICENSE-MIT\"#\"$PWD/LICENSE-MIT\"#" \ + deploy/packaging/packager-macos.toml >"$config" +cargo packager --config "$config" --formats dmg --release +rm -rf "$stage" +ls -l "$out" diff --git a/deploy/packaging/packager-macos.toml b/deploy/packaging/packager-macos.toml new file mode 100644 index 0000000..64ef6c0 --- /dev/null +++ b/deploy/packaging/packager-macos.toml @@ -0,0 +1,36 @@ +# cargo-packager's description of the macOS bundle (P11a step 6; D-24, +# D-25): the .app and its .dmg, UNSIGNED. `deploy/packaging/build-macos.sh` +# runs it on a Mac (CI: the `macos` job of .github/workflows/release.yml). +# Developer ID signing and notarization are step 5 (the owner's Apple +# certificate, Q-4): they add `signing-identity` under [macos] and the +# certificate and notarization credentials as CI secrets. +# Paths are relative to this file's copy that build-macos.sh writes. +name = "wanderbooks" +product-name = "Wanderbooks" +version = "0.1.0" +identifier = "ai.wanderbooks.app" +description = "Double-entry bookkeeping your agent keeps and you can read." +homepage = "https://wanderbooks.ai" +authors = ["Stephen Waits"] +license-file = "LICENSE-MIT" +category = "Finance" +binaries-dir = "target/release" +out-dir = "target/packages" +# A png is made into the .icns (cargo-packager does it); the 512 px +# placeholder is the owner's to replace. +icons = ["deploy/packaging/icons/512x512.png"] +formats = ["dmg"] +binaries = [{ path = "wanderbooks", main = true }] + +[macos] +# The oldest macOS Rust's tier-1 aarch64-apple-darwin and x86-64 targets +# run on; eframe's wgpu/Metal path needs 10.15 at least. +minimum-system-version = "11.0" +# Keys merged into the generated Info.plist: one viewer at a time, and the +# discrete GPU is not woken for a ledger. +info-plist-path = "deploy/packaging/Info.plist" + +[dmg] +window-size = { width = 540, height = 380 } +app-position = { x = 140, y = 180 } +application-folder-position = { x = 400, y = 180 } diff --git a/docs/architecture.md b/docs/architecture.md index 27e82bc..9e0572c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -355,9 +355,21 @@ downgrading is theirs). Anything else (an AppImage, whose file is named by without the agent noticing. The format gate stays the server's (3.9): `rollback` refuses while a book declares a format or feature the kept version does not know; -- an AppImage is the exception to the restart: the host runs from the old - image's mount, whose path does not change, so it keeps running the old - version until the books are next closed and opened (docs/packaging.md). +- an AppImage runs the program from a mount that goes away with the image, + so the host learns the image's own path from `APPIMAGE` (the one variable + `start.rs` adds to its scrubbed environment, only when it names an + absolute file; the host reads it about itself and never tells an agent), + runs and restarts from that file, and tells a replaced program by its + inode, size and last write, like any other; +- updates are also **staged** (step 4): while the books are open the host + looks at the feed at most once a day (`update_check` knob; the look is + noted in `updates/checked`), and only downloads and verifies a newer + release into `/updates/staged/`. It never swaps. The staged program + is applied by an opener just before it starts a host (no host runs, so no + call is cut off), after a second check against the key, or by + `wanderbooks update`, the owner's say-so, which reuses the staged copy. A + local release is named in `/updates/feed` (a `file://` address, then + the key file), the same rule as `--trusted-key`. `deploy/packaging/` holds the Linux packaging: `packager.toml` (cargo-packager: `.deb`, AppImage, the pacman tarball with its PKGBUILD), @@ -365,7 +377,9 @@ the rpm table in `crates/ledger-cli/Cargo.toml`, `aur/PKGBUILD` (the AUR package, which repacks the release's tarball), `build-linux.sh`, `repo-indexes.sh` (the apt and dnf indexes), `local-release.sh` (a throwaway-keyed feed on this machine) and `test-linux-packages.sh`. -`.github/workflows/release.yml` builds them on a version tag. +`.github/workflows/release.yml` builds them on a version tag. The macOS +bundle is `packager-macos.toml`, `Info.plist` and `build-macos.sh` (an +unsigned `.dmg` from CI's `macos` job; signing and notarization are step 5). ## The result envelope and the guards (`ledger-mcp`, P5) diff --git a/docs/packaging.md b/docs/packaging.md index 3ec5b02..07063da 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -16,8 +16,8 @@ not a tool, so there is nothing for a recipe to call. | `.rpm` | `cargo-generate-rpm` (table in `crates/ledger-cli/Cargo.toml`) | same paths, marker `rpm`; 36 MB | dnf repository (`repo-indexes.sh dnf`) | unsigned until step 5 (`--signing-key` takes the PGP key then) | | AUR `PKGBUILD` | `cargo packager` makes the tarball (`pacman` format) and a PKGBUILD for it; ours is `deploy/packaging/aur/PKGBUILD`, which repacks the release's tarball (package `wanderbooks-bin`) | same paths, marker `aur`; built with `makepkg` | pacman (the AUR keeps old PKGBUILDs) | n/a (pacman trusts the AUR's checksums) | | AppImage | `cargo packager` (`appimage`); 44 MB | one file the owner puts anywhere and runs; needs no admin password | `wanderbooks update` (`$APPIMAGE` is the file replaced) against the manifest; minisign | minisign by the release key (step 5) | -| `.dmg` | `cargo packager` (`dmg`) | (trial) needs a Mac | `wanderbooks update` | Developer ID and notarization (step 5) | -| NSIS `.exe` | `cargo packager` (`nsis`) | (trial) needs Windows, and native Windows is unit P11c (D-62) | `wanderbooks update` | the Windows identity (step 5) | +| `.dmg` | `cargo packager` (`dmg`), `deploy/packaging/build-macos.sh`; **built in CI, unsigned; the trial on a Mac is the owner's** | `Wanderbooks.app` dragged to Applications; no admin password (the trial will say) | `wanderbooks update` (it replaces the program inside the `.app`: see the note below) | **unsigned**; Developer ID and notarization are step 5 | +| NSIS `.exe` | `cargo packager` (`nsis`) | **not built**: native Windows is unit P11c (D-62) | `wanderbooks update` | the Windows identity (step 5) | `just package-linux` builds the first four (`deploy/packaging/build-linux.sh`, about a minute after the release build, itself 8 to 12 minutes on a @@ -64,21 +64,18 @@ What the trial found, each of which is built around now: manifest's `signature` is that `.sig` as written, and `update` reads the public key in either form. Checked end to end by hand (below), and by `a_public_key_is_read_in_every_form_minisign_and_the_packager_write_it`. -7. **The AppImage updates itself but the books it holds do not restart - onto the new image.** `update` replaces `$APPIMAGE`, and an AppImage run - from a shell (`Wanderbooks.AppImage update`, or the one that was running) - proves the swap and the rollback: after `update`, the AppImage on disk - is the new file, the old one is kept, and `rollback` restores it. But the - host that is open runs from the old image's mount +7. **The AppImage updates itself, and (since step 4) the books it holds + restart onto the new image.** `update` replaces `$APPIMAGE`. The host + that is open used to run from the old image's mount (`/tmp/.mount_*/usr/bin/wanderbooks`), a path that does not change, so - `host.rs` never sees a replaced file and the open books keep the old - version until they are closed and opened again (`wanderbooks close`). - Fixing it means the host learning the image's path (its environment is - scrubbed on purpose; `APPIMAGE` would join the allow-list of `start.rs`, - or the path would come from the mount table) and starting its successor - from it: a decision about what the host may trust, left to the EM (the - unit's report names it). Package installs and a hand-placed executable - restart as designed (`bridge_survives_update`). + it never saw a replaced file. The host now reads `APPIMAGE` (it is in + the allow-list of `start.rs`'s scrubbed environment, only when it names + an absolute file; a path the host reads about itself and never tells an + agent), runs and restarts from that file, and tells a replaced program + by its inode, size and last write. Tested without FUSE by + `a_replaced_appimage_restarts_the_books` (the programs run from one + copy while `APPIMAGE` names another, as a mount does); not run against + a real image mount in this wave. 8. **Onboarding with no terminal does not exist yet.** The unit's Done-when (1) says an AppImage "opens, onboards and posts an entry with no terminal". The AppImage opens (a bare launch is the viewer), but the viewer @@ -118,6 +115,43 @@ package manager. Not tested: Ubuntu, openSUSE, a package-manager *downgrade* (the repositories keep every release by construction), and the `.rpm` and `.deb` under their real signatures (step 5). +## The macOS bundle (step 6, built in CI, unsigned) + +`deploy/packaging/packager-macos.toml` (cargo-packager: the `dmg` format, +which makes the `.app` first; the icon is the 512 px PNG, made into an +`.icns` by the packager; `minimum-system-version` 11.0), `Info.plist` +(merged into the generated one: one viewer at a time, no discrete GPU), +`build-macos.sh` (the same shape as the Linux script, in portable shell +because macOS has BSD `sed`) and `just package-macos`. The `macos` job of +`.github/workflows/release.yml` runs on `macos-latest` (Apple silicon: the +manifest key is `macos-aarch64`; an Intel build is not made), uploads the +`.dmg` as a run artifact (named `macos-dmg-unsigned`) and, on a tag, +attaches it to the release. **It is not signed or notarized** (step 5: the +owner's Developer ID and Q-4), so Gatekeeper refuses it on a Mac that +downloaded it; the owner opens it with right-click, Open. What was checked +here, on Linux: the config parses under cargo-packager 0.11.8's strict +schema (unknown keys are errors) and the script's `sed` is the portable +subset. **Not checked: that the `.dmg` builds, mounts or runs**: that needs +the CI run (Actions, release, Run workflow, this branch) and then a Mac. +Open items for the trial on a Mac: whether `wanderbooks update` replacing +the program inside a signed `.app` breaks its signature (it will: step 5 +must decide, probably by updating a whole `.app`); the translocated-app +check (`translocated_app_refuses_to_connect`) is not built. + +## Updates staged by the host (step 4) + +The host looks at the feed at most once a day while the books are open +(`update_check`), and downloads and verifies a newer release into +`/updates/staged/` with a `meta.json` (version, sha256, signature, +notes). It never swaps. The next opener that starts a host applies it +first; `wanderbooks update` applies it too and reuses the staged copy; +`update --check` says "already downloaded". A local release is named by +`/updates/feed` (a `file://` address, then the key file). Without a +compiled-in release key and without that file, the look does nothing but +log why. Not built: the in-app "restart now to update" choice and the +viewer's notice, which need an admin-socket intent and a place in the +viewer (see the unit's As-built block). + ## What `update` and `rollback` need from a release `manifest.json` at `releases/latest/download/` (D-26): diff --git a/docs/testing.md b/docs/testing.md index a643a37..17bab10 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3711,6 +3711,25 @@ it (the same program with a tail, so its hash differs). All in file now on disk (not a deleted one). The host's own restart laws stay `open_while_held.rs`'s (`a_restart_is_not_a_close`). +Step 3's gap and step 4 (same file): + +- `a_replaced_appimage_restarts_the_books`: the programs run from one copy + (a mount's) while `APPIMAGE` names another (the image); `update` + replaces only the image; the host restarts onto it (its `/proc/PID/exe` + is the image), the mount's copy is untouched, the session writes on. +- `the_host_stages_a_daily_update_and_the_next_start_applies_it`: with a + local feed named in `updates/feed`, the host stages 9.0.0 by itself; + the program, the host and the session are unchanged meanwhile; after + `close`, the next opener applies it (the old program is kept, the staged + folder gone) and the books are as they were. +- `a_daily_update_that_is_not_ours_is_not_staged`: a signature by another + key, then a wrong sha256, each looked at and neither staged. +- `update_installs_the_copy_the_host_staged_without_downloading_again`: the + release's file is deleted; `update` installs from the staged copy. +- `a_staged_update_that_was_tampered_with_is_dropped_not_applied`: a staged + file edited together with its recorded checksum fails the signature; the + next start drops it and runs the old program. + Unit tests in `update.rs`: `versions_are_three_numbers_and_order_by_number`, `a_package_marker_beside_the_program_makes_it_package_managed`, `a_manifest_address_is_resolved_beside_the_manifest`, diff --git a/justfile b/justfile index a6d248e..462aa66 100644 --- a/justfile +++ b/justfile @@ -273,6 +273,11 @@ package-linux version="" formats="deb,appimage,pacman": cargo build --release -p ledger-cli deploy/packaging/build-linux.sh {{version}} {{formats}} +# Build the unsigned macOS .dmg (on a Mac; CI does it on a tag or dispatch) +package-macos version="": + cargo build --release -p ledger-cli + deploy/packaging/build-macos.sh {{version}} + # A release folder on this machine signed with a throwaway key; then # `wanderbooks update --feed file://DIR/manifest.json --trusted-key DIR/release.pub` # Make a local release feed to try `wanderbooks update` against diff --git a/plan/served.md b/plan/served.md index 10e279a..efcb8bc 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1658,7 +1658,7 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 5. **Files:** owns `crates/ledger-host/src/update.rs`, `deploy/packaging/*`, `.github/workflows/release.yml`, `docs/packaging.md`; touches `commands.rs`, `crates/ledger-gui/src/owner.rs`, `mise.toml`. 6. **Tests and proofs:** named above. 7. **Done when:** (1) on a clean Linux VM the AppImage installs, opens, onboards and posts an entry with no terminal and no admin password, and updates itself from a test feed; (2) on Arch, Debian and Fedora the packages install and upgrade with the head unchanged; (3) an update leaves every book's head and goldens unchanged and a bridge session carries on across it; (4) a backup made on one system restores on another; (5) macOS and Windows: the same as (1), once the signing prerequisites exist (record which are done in the As-built block); (6) `just check` green. -8. **As built:** (steps 0 to 3 as far as stated, 2026-10-29; steps 4 to 7 are later waves.) **Step 0, partly.** Built: the macOS login Keychain store (`apple-native-keyring-store` 1.0.2, feature `keychain`) and the Windows Credential Manager store (`windows-native-keyring-store` 1.1.0) behind `tokens.rs::Keeper`, on the same `Items` trait and the same laws as the Secret Service (`os` modules per OS; shared `refused`, `make_in`, `read_item`, `remove_item`); `NOT_YET` now only for other OSes; the ignored `os_keyring_keeps_and_forgets` compiles on all three; the `portable` job in `.github/workflows/check.yml` (`macos-latest` gating, `windows-latest` allowed to fail) builds `ledger-cli --no-default-features`, `cargo build --workspace --all-targets`, `cargo test --workspace` and the ignored keyring test. Checked here: Linux only (fmt, clippy, the tokens tests); no macOS/Windows target is installed, so both stores and the job are proven only by the first CI run. **Not built, and not a `cfg` gap: the Windows host.** Windows data root from the known-folder API with an owner-only DACL, named pipes with a SID check, `flock` and the Unix-only `host`, `start`, `bridge`, `opener` and `local`/`admin` sockets are a port of several crates (inventory in `docs/packaging.md`); macOS needs the CI run to show which Linux-only items (`seccomp` in `refuse.rs`, `renameat2`, `close_range`) need fences. So the step 0 Done-when ("setup ... gui work on each OS") is open for both. **Step 1 (the trial): not run in that wave**: it needs clean macOS, Windows and Linux machines; `mise.toml` now pins `cargo-packager` 0.11.8, `cargo-packager-updater` 0.2.3, `cargo-generate-rpm` 0.21.1 and `docs/packaging.md` holds the empty table and what is known. **Steps 1 (Linux half), 2 and 3 (2026-10-29, branch `P11a`).** *Pins:* the EM removed step 0's early pins; `mise.toml` now pins `cargo-packager` 0.11.8 and `cargo-generate-rpm` 0.21.1, which `just package-linux` calls; **`cargo-packager-updater` is a library, not a tool, so it is not pinned** (the plan's three-tool list was wrong on that); `update.rs` verifies with `minisign-verify` 0.3 (the crate the updater uses) and the tests sign with the `minisign` 0.10 dev-dependency. *Step 1:* `docs/packaging.md` has the Linux rows filled from real builds (47 MB .deb, 36 MB .rpm, 44 MB AppImage, the pacman tarball and PKGBUILD) and eight findings; the `.dmg` and NSIS rows stay "(trial)" (need a Mac and Windows; Windows is P11c). The findings that shaped the build: a binary linked on this machine (glibc 2.44) does not start on Debian 13, so packages are built on `ubuntu-24.04` in CI and in `rust:1.99-trixie` locally (`just release-trixie`); the books refuse to run under Docker's default seccomp filter (tests use `seccomp=unconfined`); an AppImage's launcher appends arguments to the desktop file's `Exec`, so the desktop files say plain `wanderbooks` and a bare launch with no terminal is the viewer (`main.rs::is_bare_launch`); the AppImage needs a square icon (**a placeholder PNG is committed; the owner should replace it**). *Step 2:* `crates/ledger-host/src/update.rs` (`update`, `rollback`) and the two CLI modes: a manifest (version; per platform `url`, `sha256`, `signature` = base64 of the minisign `.sig`, the shape cargo-packager signs in), accepted only if higher, sha256 equal AND signature valid against `WANDERBOOKS_RELEASE_PUBKEY` (compiled in; **no build carries one until step 5**, so today a build updates only from a `file://` feed with `--trusted-key`); stage under `/updates//`, keep the running version under `updates/previous/` with the journal format it knew, swap by renaming a complete copy made beside the target, `--help` health check with automatic put-back; `rollback` refuses when a book declares a format or feature the kept version lacks; a package marks itself with `share/wanderbooks/install-method` and then both refuse. The restart is the host's existing one (nothing new in `host.rs`). Tests: `crates/ledger-cli/tests/update.rs` (`update_is_atomic`, `failed_update_rolls_back`, `unsigned_update_refused`, `bridge_survives_update`, `update_waits_for_a_running_call`, `package_managed_install_refuses_update`, `rollback_refuses_after_a_book_consented_to_a_newer_format`, `an_update_replaces_the_program_keeps_the_old_one_and_rollback_brings_it_back`) all pass (8 in 12 s); five unit tests in `update.rs`. *Step 3:* `deploy/packaging/` (`packager.toml`, the rpm table in `crates/ledger-cli/Cargo.toml`, `aur/PKGBUILD`, `build-linux.sh`, `repo-indexes.sh`, `local-release.sh`, `test-linux-packages.sh`), the `just` recipes `package-linux`, `local-release`, `release-trixie`, `test-linux-packages`, and `.github/workflows/release.yml` (Linux packages on a `v*` tag, attached to the release, apt and dnf indexes pushed to `gh-pages`; untested, GitHub only). `linux_packages_install_and_upgrade` is `test-linux-packages.sh`: install 0.1.0 from an apt, a dnf repository and `makepkg` over the AUR PKGBUILD in Debian trixie, Fedora and Arch containers, hold books open as an ordinary user, upgrade to 0.1.1, host restarted onto the new file, journal head unchanged, `update` refuses: passes on all three. By hand: the AppImage updated itself and rolled back against a feed signed by `cargo packager signer` (`just local-release`). *Done-when so far:* (1) **partly**: the AppImage opens (bare launch) and updates itself from a test feed, but "onboards and posts an entry with no terminal" is false today (the viewer opens only an existing book and `setup`/`books create` need a terminal; not in these steps), and a held-open books' host does not restart onto an updated AppImage (it runs from the old image's mount; fix needs the host to learn the image's path, a trust decision left to the EM); (2) true on Debian, Fedora and Arch containers; (3) true for a hand-placed program (`bridge_survives_update`) and for packages (the container test), not yet for the AppImage's host; (4) not touched; (5) and (6) not done (macOS/Windows need machines and signing; `just check` is the EM's). *Not done, as instructed:* step 4 (daily check, staged update applied at restart), step 5 (signing, Q-4), step 6 (macOS and Windows bundles), step 7 (architecture note is written; the website and `docs/parity.md` are not touched, since nothing is published). +8. **As built:** (steps 0 to 3 as far as stated, 2026-10-29; steps 4 to 7 are later waves.) **Step 0, partly.** Built: the macOS login Keychain store (`apple-native-keyring-store` 1.0.2, feature `keychain`) and the Windows Credential Manager store (`windows-native-keyring-store` 1.1.0) behind `tokens.rs::Keeper`, on the same `Items` trait and the same laws as the Secret Service (`os` modules per OS; shared `refused`, `make_in`, `read_item`, `remove_item`); `NOT_YET` now only for other OSes; the ignored `os_keyring_keeps_and_forgets` compiles on all three; the `portable` job in `.github/workflows/check.yml` (`macos-latest` gating, `windows-latest` allowed to fail) builds `ledger-cli --no-default-features`, `cargo build --workspace --all-targets`, `cargo test --workspace` and the ignored keyring test. Checked here: Linux only (fmt, clippy, the tokens tests); no macOS/Windows target is installed, so both stores and the job are proven only by the first CI run. **Not built, and not a `cfg` gap: the Windows host.** Windows data root from the known-folder API with an owner-only DACL, named pipes with a SID check, `flock` and the Unix-only `host`, `start`, `bridge`, `opener` and `local`/`admin` sockets are a port of several crates (inventory in `docs/packaging.md`); macOS needs the CI run to show which Linux-only items (`seccomp` in `refuse.rs`, `renameat2`, `close_range`) need fences. So the step 0 Done-when ("setup ... gui work on each OS") is open for both. **Step 1 (the trial): not run in that wave**: it needs clean macOS, Windows and Linux machines; `mise.toml` now pins `cargo-packager` 0.11.8, `cargo-packager-updater` 0.2.3, `cargo-generate-rpm` 0.21.1 and `docs/packaging.md` holds the empty table and what is known. **Steps 1 (Linux half), 2 and 3 (2026-10-29, branch `P11a`).** *Pins:* the EM removed step 0's early pins; `mise.toml` now pins `cargo-packager` 0.11.8 and `cargo-generate-rpm` 0.21.1, which `just package-linux` calls; **`cargo-packager-updater` is a library, not a tool, so it is not pinned** (the plan's three-tool list was wrong on that); `update.rs` verifies with `minisign-verify` 0.3 (the crate the updater uses) and the tests sign with the `minisign` 0.10 dev-dependency. *Step 1:* `docs/packaging.md` has the Linux rows filled from real builds (47 MB .deb, 36 MB .rpm, 44 MB AppImage, the pacman tarball and PKGBUILD) and eight findings; the `.dmg` and NSIS rows stay "(trial)" (need a Mac and Windows; Windows is P11c). The findings that shaped the build: a binary linked on this machine (glibc 2.44) does not start on Debian 13, so packages are built on `ubuntu-24.04` in CI and in `rust:1.99-trixie` locally (`just release-trixie`); the books refuse to run under Docker's default seccomp filter (tests use `seccomp=unconfined`); an AppImage's launcher appends arguments to the desktop file's `Exec`, so the desktop files say plain `wanderbooks` and a bare launch with no terminal is the viewer (`main.rs::is_bare_launch`); the AppImage needs a square icon (**a placeholder PNG is committed; the owner should replace it**). *Step 2:* `crates/ledger-host/src/update.rs` (`update`, `rollback`) and the two CLI modes: a manifest (version; per platform `url`, `sha256`, `signature` = base64 of the minisign `.sig`, the shape cargo-packager signs in), accepted only if higher, sha256 equal AND signature valid against `WANDERBOOKS_RELEASE_PUBKEY` (compiled in; **no build carries one until step 5**, so today a build updates only from a `file://` feed with `--trusted-key`); stage under `/updates//`, keep the running version under `updates/previous/` with the journal format it knew, swap by renaming a complete copy made beside the target, `--help` health check with automatic put-back; `rollback` refuses when a book declares a format or feature the kept version lacks; a package marks itself with `share/wanderbooks/install-method` and then both refuse. The restart is the host's existing one (nothing new in `host.rs`). Tests: `crates/ledger-cli/tests/update.rs` (`update_is_atomic`, `failed_update_rolls_back`, `unsigned_update_refused`, `bridge_survives_update`, `update_waits_for_a_running_call`, `package_managed_install_refuses_update`, `rollback_refuses_after_a_book_consented_to_a_newer_format`, `an_update_replaces_the_program_keeps_the_old_one_and_rollback_brings_it_back`) all pass (8 in 12 s); five unit tests in `update.rs`. *Step 3:* `deploy/packaging/` (`packager.toml`, the rpm table in `crates/ledger-cli/Cargo.toml`, `aur/PKGBUILD`, `build-linux.sh`, `repo-indexes.sh`, `local-release.sh`, `test-linux-packages.sh`), the `just` recipes `package-linux`, `local-release`, `release-trixie`, `test-linux-packages`, and `.github/workflows/release.yml` (Linux packages on a `v*` tag, attached to the release, apt and dnf indexes pushed to `gh-pages`; untested, GitHub only). `linux_packages_install_and_upgrade` is `test-linux-packages.sh`: install 0.1.0 from an apt, a dnf repository and `makepkg` over the AUR PKGBUILD in Debian trixie, Fedora and Arch containers, hold books open as an ordinary user, upgrade to 0.1.1, host restarted onto the new file, journal head unchanged, `update` refuses: passes on all three. By hand: the AppImage updated itself and rolled back against a feed signed by `cargo packager signer` (`just local-release`). *Done-when so far:* (1) **partly**: the AppImage opens (bare launch) and updates itself from a test feed, but "onboards and posts an entry with no terminal" is false today (the viewer opens only an existing book and `setup`/`books create` need a terminal; not in these steps), and a held-open books' host does not restart onto an updated AppImage (it runs from the old image's mount; fix needs the host to learn the image's path, a trust decision left to the EM); (2) true on Debian, Fedora and Arch containers; (3) true for a hand-placed program (`bridge_survives_update`) and for packages (the container test), not yet for the AppImage's host; (4) not touched; (5) and (6) not done (macOS/Windows need machines and signing; `just check` is the EM's). *Not done, as instructed:* step 4 (daily check, staged update applied at restart), step 5 (signing, Q-4), step 6 (macOS and Windows bundles), step 7 (architecture note is written; the website and `docs/parity.md` are not touched, since nothing is published). **Step 3's AppImage gap, step 4 and the macOS half of step 6 (2026-10-29, branch `P11a`).** *AppImage restart (EM decision):* `start.rs` lets `APPIMAGE` into the host's environment only when it is an absolute file (`own_appimage`; read about the host itself, never told to an agent, I-2), `this_executable()` returns that image, and `host.rs` tells a replaced program by (dev, inode, size, mtime) instead of (dev, inode); test `a_replaced_appimage_restarts_the_books` (the programs run from one copy, `APPIMAGE` names another). Not run against a real FUSE mount. *Step 4:* `update.rs::stage_if_due` (the host's look, at most once per `update_check` knob, default 24 h, noted in `updates/checked`; from a thread of the host's loop; downloads, checks sha256 and signature, writes `/updates/staged/{wanderbooks,meta.json}`; never swaps), `apply_staged` (called by `start.rs::start_host` before it starts a host, so never during a call; re-checks the signature; a staged file that fails or does not start is dropped), `update` reuses a staged copy and `--check` says it is downloaded, `/updates/feed` names a local `file://` feed and its key (INVARIANT 8's rule), `set_program_version` because packages rewrite only the cli's version. Tests in `crates/ledger-cli/tests/update.rs`: `the_host_stages_a_daily_update_and_the_next_start_applies_it`, `a_daily_update_that_is_not_ours_is_not_staged`, `update_installs_the_copy_the_host_staged_without_downloading_again`, `a_staged_update_that_was_tampered_with_is_dropped_not_applied`; all 13 in the file pass; `ledger-host` lib tests pass (74). **Not built, needs a name or a decision:** "applied at the next restart the owner accepts in the app" and the `books.start`/viewer notice. The accept is a new lease/admin intent (`Intent` in `lease.rs`) and the notice a place in the viewer (P11b's files); neither name is allocated in 8.0.3, so the owner accepts today with `wanderbooks update`, and `update --check` and the host log say a version is waiting. *macOS bundle:* `deploy/packaging/{packager-macos.toml,Info.plist,build-macos.sh}`, `just package-macos`, a `macos` job in `release.yml` (`macos-latest`, Apple silicon, uploads the `.dmg` as run artifact `macos-dmg-unsigned`, attaches it on a tag), `docs/packaging.md` macOS rows "built in CI, unsigned; the trial on a Mac is the owner's". Checked on Linux only: the config parses under cargo-packager 0.11.8's strict schema; the `.dmg` itself is unverified until a CI run (the owner: Actions > release > Run workflow > branch `P11a`; the EM pushes the branch first). Not built: translocated-app refusal, "Check for updates" in the viewer's menu, the Intel build, the Windows installer (P11c), signing (step 5). Known issue for step 5: `update` replaces the program inside the `.app`, which breaks a signed bundle's seal; step 5 must update a whole `.app`. #### P11b: the agent dock and books homes (D-27)