merge: P11a, the AppImage restart, the daily staged update, the unsigned macOS bundle in CI (branch P11a, 310fa105)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 16:29:21 -06:00
commit 6f948592f6
15 files changed
+843 -75

No files matched your search

+48 -2
View File
@@ -3,8 +3,11 @@
# program linked on a newer one does not start on an older one), attach # 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 # 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 # 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 # the unit (the release key, Q-4) signs the manifest and the indexes. The
# step 6 adds the macOS and Windows jobs. The release holds no key today. # 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 name: release
on: on:
@@ -75,3 +78,46 @@ jobs:
git add -A git add -A
git commit -m "wanderbooks $v" git commit -m "wanderbooks $v"
git push origin HEAD:gh-pages 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
+4 -1
View File
@@ -331,7 +331,9 @@ fn run_local(mode: &str, args: Vec<String>) -> i32 {
"token" => ledger_host::commands::token(&prog, args), "token" => ledger_host::commands::token(&prog, args),
"connect" => ledger_host::commands::connect(&prog, args), "connect" => ledger_host::commands::connect(&prog, args),
"passphrase" => ledger_host::commands::passphrase(&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), "rollback" => ledger_host::update::rollback(&prog, args),
_ => ledger_host::commands::books(&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() { 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 // A file's path may be any name the system takes: the file commands
// get the words as given. Everything else is text. // get the words as given. Everything else is text.
let given: Vec<std::ffi::OsString> = env::args_os().skip(1).collect(); let given: Vec<std::ffi::OsString> = env::args_os().skip(1).collect();
+255 -25
View File
@@ -4,7 +4,8 @@
//! and host.rs; the P11a tests update_is_atomic, failed_update_rolls_back, //! and host.rs; the P11a tests update_is_atomic, failed_update_rolls_back,
//! unsigned_update_refused, bridge_survives_update, //! unsigned_update_refused, bridge_survives_update,
//! update_waits_for_a_running_call, package_managed_install_refuses_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")] #![cfg(target_os = "linux")]
mod common; mod common;
@@ -16,8 +17,7 @@ use std::process::{Command, Stdio};
use std::sync::mpsc; use std::sync::mpsc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
const KNOBS: &str = const KNOBS: &str = "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300,update_check=400";
"close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300";
/// The platform key a manifest names, as the program computes it. /// The platform key a manifest names, as the program computes it.
fn platform() -> String { fn platform() -> String {
@@ -76,6 +76,10 @@ fn base_program() -> &'static Path {
struct Home { struct Home {
dir: tempfile::TempDir, dir: tempfile::TempDir,
exe: PathBuf, 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<PathBuf>,
} }
impl Home { impl Home {
@@ -95,7 +99,22 @@ impl Home {
std::fs::create_dir(&bin).unwrap(); std::fs::create_dir(&bin).unwrap();
let exe = bin.join("wanderbooks"); let exe = bin.join("wanderbooks");
std::fs::copy(base_program(), &exe).unwrap(); 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.runnable();
home home
} }
@@ -103,25 +122,35 @@ impl Home {
/// Wait until the copy can be run (a child another test thread forked /// Wait until the copy can be run (a child another test thread forked
/// while it was written may hold it open for a moment). /// while it was written may hold it open for a moment).
fn runnable(&self) { fn runnable(&self) {
let deadline = Instant::now() + Duration::from_secs(10); for exe in std::iter::once(&self.exe).chain(self.image.as_ref()) {
loop { let deadline = Instant::now() + Duration::from_secs(10);
match Command::new(&self.exe) loop {
.arg("--help") match Command::new(exe)
.stdin(Stdio::null()) .arg("--help")
.stdout(Stdio::null()) .env("APPIMAGE_EXTRACT_AND_RUN", "1")
.stderr(Stdio::null()) .stdin(Stdio::null())
.status() .stdout(Stdio::null())
{ .stderr(Stdio::null())
Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => { .status()
assert!(Instant::now() < deadline, "the copy stayed busy"); {
std::thread::sleep(Duration::from_millis(20)); 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 { fn root(&self) -> PathBuf {
self.dir.path().join(".local/share/wanderbooks") self.dir.path().join(".local/share/wanderbooks")
} }
@@ -139,11 +168,13 @@ impl Home {
.env_clear() .env_clear()
.env("PATH", "/usr/bin:/bin") .env("PATH", "/usr/bin:/bin")
.env("HOME", self.dir.path()); .env("HOME", self.dir.path());
self.image_env(&mut c);
c c
} }
/// The program that updates replace: the image, if there is one.
fn program(&self) -> Vec<u8> { fn program(&self) -> Vec<u8> {
std::fs::read(&self.exe).unwrap() std::fs::read(self.image.as_ref().unwrap_or(&self.exe)).unwrap()
} }
fn create(&self, id: &str) { fn create(&self, id: &str) {
@@ -180,6 +211,7 @@ impl Home {
.env("PATH", "/usr/bin:/bin") .env("PATH", "/usr/bin:/bin")
.env("HOME", self.dir.path()) .env("HOME", self.dir.path())
.stdin(Stdio::null()); .stdin(Stdio::null());
self.image_env(&mut c);
c.output().unwrap() 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 /// host and server run the new file afterwards, and the head is the one
/// the writes made. /// the writes made.
fn update_under_writes(tag: &str) -> (Home, Agent, Vec<String>) { fn update_under_writes(tag: &str) -> (Home, Agent, Vec<String>) {
let home = Home::new(tag); update_under_writes_in(Home::new(tag))
}
fn update_under_writes_in(home: Home) -> (Home, Agent, Vec<String>) {
let tag = "under-writes";
home.create("acme"); home.create("acme");
let release = Release::new(); let release = Release::new();
let next = newer(&home, tag); let next = newer(&home, tag);
@@ -673,18 +709,21 @@ fn update_under_writes(tag: &str) -> (Home, Agent, Vec<String>) {
release.dir.path().join("manifest.json").display() release.dir.path().join("manifest.json").display()
); );
let key = release.dir.path().join("release.pub"); let key = release.dir.path().join("release.pub");
let image = home.image.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let _keep = &release; let _keep = &release;
Command::new(exe) let mut c = Command::new(exe);
.args(["update", "--root"]) c.args(["update", "--root"])
.arg(root) .arg(root)
.args(["--feed", &feed, "--trusted-key"]) .args(["--feed", &feed, "--trusted-key"])
.arg(key) .arg(key)
.env_clear() .env_clear()
.env("PATH", "/usr/bin:/bin") .env("PATH", "/usr/bin:/bin")
.env("HOME", dir) .env("HOME", dir);
.output() if let Some(image) = image {
.unwrap() c.env("APPIMAGE", image);
}
c.output().unwrap()
}) })
}; };
let mut written = vec!["4000".to_string()]; let mut written = vec!["4000".to_string()];
@@ -742,6 +781,39 @@ fn bridge_survives_update() {
assert!(in_books >= written.len(), "{in_books} < {}", written.len()); 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] #[test]
fn update_waits_for_a_running_call() { fn update_waits_for_a_running_call() {
// No call is cut off or made twice: each account written across the // 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" "a write lost, or made twice, across the update"
); );
} }
/// Name `release` as this home's local feed, as the owner would
/// (`<root>/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<String> {
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");
}
+46 -8
View File
@@ -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 /// The running executable's file identity, and its file's now: they
/// differ once the file was replaced (INVARIANT 7). /// differ once the file was replaced (INVARIANT 7). An AppImage is its
fn running_identity() -> Option<(u64, u64)> { /// image file (`APPIMAGE`), not the program inside the mount.
fn running_identity() -> Option<Identity> {
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") { let running = if cfg!(target_os = "linux") {
std::fs::metadata("/proc/self/exe").ok()? std::fs::metadata("/proc/self/exe").ok()?
} else { } else {
std::fs::metadata(std::env::current_exe().ok()?).ok()? 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 /// 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). /// program (a file half-written or gone doesn't count yet).
fn file_identity() -> Option<(u64, u64)> { fn file_identity() -> Option<Identity> {
identity_of(&crate::start::this_executable().ok()?) identity_of(&crate::start::this_executable().ok()?)
} }
/// The identity (device, inode) of the program at `path`: a file someone /// The identity of the program at `path`: a file someone may run; else
/// may run; else None. /// None.
fn identity_of(path: &Path) -> Option<(u64, u64)> { fn identity_of(path: &Path) -> Option<Identity> {
let meta = std::fs::metadata(path).ok()?; 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 /// 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 started_as = running_identity();
let mut exe_poll = tokio::time::interval(self.knobs.exe_poll); let mut exe_poll = tokio::time::interval(self.knobs.exe_poll);
exe_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); 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 mut idle_at = Some(Instant::now() + self.knobs.close_after);
let server_start = tokio::time::sleep(self.knobs.server_delay); let server_start = tokio::time::sleep(self.knobs.server_delay);
tokio::pin!(server_start); 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) => { _ = exe_poll.tick(), if matches!(self.phase, Phase::Open) => {
if started_as.is_some() && file_identity().is_some_and(|now| Some(now) != started_as) { if started_as.is_some() && file_identity().is_some_and(|now| Some(now) != started_as) {
eprintln!("wanderbooks host: a new version is installed: restarting"); eprintln!("wanderbooks host: a new version is installed: restarting");
+9 -1
View File
@@ -33,6 +33,9 @@ pub struct Knobs {
/// How long after books open the server first looks for a drill due /// How long after books open the server first looks for a drill due
/// (60 s), passed to the server as `--drill-first`. /// (60 s), passed to the server as `--drill-first`.
pub drill_first: Duration, 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 { impl Default for Knobs {
@@ -47,6 +50,7 @@ impl Default for Knobs {
open_wait: Duration::from_secs(120), open_wait: Duration::from_secs(120),
backup_quiet: Duration::from_secs(30), backup_quiet: Duration::from_secs(30),
drill_first: Duration::from_secs(60), 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, "open_wait" => k.open_wait = d,
"backup_quiet" => k.backup_quiet = d, "backup_quiet" => k.backup_quiet = d,
"drill_first" => k.drill_first = d, "drill_first" => k.drill_first = d,
"update_check" => k.update_check = d,
other => return Err(format!("{FLAG}: there is no knob {other:?}")), other => return Err(format!("{FLAG}: there is no knob {other:?}")),
} }
} }
@@ -97,6 +102,7 @@ impl Knobs {
("open_wait", self.open_wait, d.open_wait), ("open_wait", self.open_wait, d.open_wait),
("backup_quiet", self.backup_quiet, d.backup_quiet), ("backup_quiet", self.backup_quiet, d.backup_quiet),
("drill_first", self.drill_first, d.drill_first), ("drill_first", self.drill_first, d.drill_first),
("update_check", self.update_check, d.update_check),
] { ] {
if now != default { if now != default {
parts.push(format!("{name}={}", now.as_millis())); 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.open_wait, Duration::from_secs(120));
assert_eq!(k.backup_quiet, Duration::from_secs(30)); assert_eq!(k.backup_quiet, Duration::from_secs(30));
assert_eq!(k.drill_first, Duration::from_secs(60)); 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_eq!(k.to_arg(), None);
assert!(k.args().is_empty()); assert!(k.args().is_empty());
assert_eq!(Knobs::parse("").unwrap(), k); assert_eq!(Knobs::parse("").unwrap(), k);
@@ -138,7 +145,7 @@ mod tests {
#[test] #[test]
fn knobs_pass_on_exactly() { fn knobs_pass_on_exactly() {
let k = Knobs::parse( 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(); .unwrap();
assert_eq!(k.close_after, Duration::from_millis(500)); 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.open_wait, Duration::from_millis(70));
assert_eq!(k.backup_quiet, Duration::from_millis(40)); assert_eq!(k.backup_quiet, Duration::from_millis(40));
assert_eq!(k.drill_first, Duration::from_millis(50)); assert_eq!(k.drill_first, Duration::from_millis(50));
assert_eq!(k.update_check, Duration::from_millis(60));
let args = k.args(); let args = k.args();
assert_eq!(args[0], FLAG); assert_eq!(args[0], FLAG);
assert_eq!(Knobs::parse(&args[1]).unwrap(), k); assert_eq!(Knobs::parse(&args[1]).unwrap(), k);
+51 -7
View File
@@ -13,7 +13,8 @@
//! it names a socket this account owns (the owner's ssh agent, so the //! 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 //! server's backups reach a private git repository, D-30). Nothing
//! else passes (a scope's own identifiers, such as INVOCATION_ID, are //! 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 //! 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 //! 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 //! 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 //! scope couldn't be made (systemd-run failed without the host ever
//! writing its first line), or there is no such scope, directly. A //! writing its first line), or there is no such scope, directly. A
//! host that ran and stopped is never started a second time. //! 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. //! 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 /// replaced file reads as "… (deleted)"; the path, not the old file, is
/// what a new host runs. /// what a new host runs.
pub fn this_executable() -> Result<PathBuf, String> { pub fn this_executable() -> Result<PathBuf, String> {
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 exe = std::env::current_exe().map_err(|e| format!("can't find this program: {e}"))?;
let text = exe.to_string_lossy(); let text = exe.to_string_lossy();
if let Some(path) = text.strip_suffix(" (deleted)") if let Some(path) = text.strip_suffix(" (deleted)")
@@ -59,6 +67,13 @@ pub fn this_executable() -> Result<PathBuf, String> {
Ok(exe) 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<std::ffi::OsString> {
let path = Path::new(value?);
(path.is_absolute() && path.is_file()).then(|| path.as_os_str().to_os_string())
}
/// This account's user ID. /// This account's user ID.
fn own_uid() -> u32 { fn own_uid() -> u32 {
// SAFETY: geteuid has no preconditions and can't fail. // SAFETY: geteuid has no preconditions and can't fail.
@@ -128,7 +143,11 @@ fn own_agent(named: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
/// The host's environment (INVARIANT 1), given the opener's /// The host's environment (INVARIANT 1), given the opener's
/// `SSH_AUTH_SOCK`. /// `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![ let mut env = vec![
("PATH".to_string(), PATH.into()), ("PATH".to_string(), PATH.into()),
("HOME".to_string(), home.as_os_str().to_os_string()), ("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) { if let Some(agent) = own_agent(ssh_agent) {
env.push(("SSH_AUTH_SOCK".into(), agent)); env.push(("SSH_AUTH_SOCK".into(), agent));
} }
if let Some(image) = own_appimage(appimage) {
env.push(("APPIMAGE".into(), image));
}
env env
} }
@@ -183,6 +205,10 @@ pub fn start_host(
let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?; 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 run = found.run_dir().map_err(|e| e.to_string())?;
let home = ledger_store::root::account_home().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 exe = this_executable()?;
let mut args: Vec<std::ffi::OsString> = vec!["host".into()]; let mut args: Vec<std::ffi::OsString> = vec!["host".into()];
// An explicit root goes on as found (absolute): the host starts in /. // An explicit root goes on as found (absolute): the host starts in /.
@@ -227,6 +253,7 @@ pub fn start_host(
.envs(host_env( .envs(host_env(
&home, &home,
std::env::var_os("SSH_AUTH_SOCK").as_deref(), std::env::var_os("SSH_AUTH_SOCK").as_deref(),
std::env::var_os("APPIMAGE").as_deref(),
)) ))
.current_dir("/") .current_dir("/")
.stdin(Stdio::null()) .stdin(Stdio::null())
@@ -385,7 +412,7 @@ mod tests {
#[test] #[test]
fn the_host_environment_carries_nothing_else() { 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(); let names: Vec<&str> = env.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(&names[..2], ["PATH", "HOME"]); assert_eq!(&names[..2], ["PATH", "HOME"]);
for n in &names[2..] { for n in &names[2..] {
@@ -393,7 +420,8 @@ mod tests {
[ [
"XDG_RUNTIME_DIR", "XDG_RUNTIME_DIR",
"DBUS_SESSION_BUS_ADDRESS", "DBUS_SESSION_BUS_ADDRESS",
"SSH_AUTH_SOCK" "SSH_AUTH_SOCK",
"APPIMAGE"
] ]
.contains(n), .contains(n),
"{n}" "{n}"
@@ -403,6 +431,22 @@ mod tests {
assert_eq!(env[1].1, "/home/someone"); 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] #[test]
fn only_an_own_ssh_agent_socket_passes() { fn only_an_own_ssh_agent_socket_passes() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -413,7 +457,7 @@ mod tests {
Some(sock.clone().into_os_string()) Some(sock.clone().into_os_string())
); );
// It reaches the host's environment, once. // 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(); let agents: Vec<_> = env.iter().filter(|(n, _)| n == "SSH_AUTH_SOCK").collect();
assert_eq!(agents.len(), 1); assert_eq!(agents.len(), 1);
assert_eq!(agents[0].1, sock.as_os_str()); assert_eq!(agents[0].1, sock.as_os_str());
@@ -422,7 +466,7 @@ mod tests {
let link = dir.path().join("link.sock"); let link = dir.path().join("link.sock");
std::os::unix::fs::symlink(&sock, &link).unwrap(); std::os::unix::fs::symlink(&sock, &link).unwrap();
assert_eq!(own_agent(Some(link.as_os_str())), None); 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")); assert!(env.iter().all(|(n, _)| n != "SSH_AUTH_SOCK"));
// Named relative to here: not taken, even where it leads to one. // Named relative to here: not taken, even where it leads to one.
let here = std::env::current_dir().unwrap(); let here = std::env::current_dir().unwrap();
+256 -10
View File
@@ -36,7 +36,17 @@
//! newer one: the only way back is a restore, plan 3.9). //! newer one: the only way back is a restore, plan 3.9).
//! 7. A package-managed install is never touched. //! 7. A package-managed install is never touched.
//! 8. A program with no release key compiled in updates from nothing //! 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 `<root>/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 `<root>/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: //! SCENARIOS:
//! 1. IF the feed offers a higher version, signed by our key: SHOULD //! 1. IF the feed offers a higher version, signed by our key: SHOULD
//! replace the program and keep the old one. //! replace the program and keep the old one.
@@ -44,6 +54,10 @@
//! is by another key or missing: SHOULD refuse, changing nothing. //! is by another key or missing: SHOULD refuse, changing nothing.
//! 3. IF the new program can't start: SHOULD put the old one back. //! 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. //! 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; use serde::Deserialize;
@@ -72,6 +86,26 @@ const UPDATES: &str = "updates";
const PREVIOUS: &str = "previous"; const PREVIOUS: &str = "previous";
const PROGRAM: &str = "wanderbooks"; const PROGRAM: &str = "wanderbooks";
const META: &str = "meta.json"; 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_MANIFEST: usize = 1 << 20;
const MAX_PROGRAM: u64 = 1 << 30; const MAX_PROGRAM: u64 = 1 << 30;
@@ -244,11 +278,18 @@ pub fn update(
Install::SelfUpdating(target) => target, Install::SelfUpdating(target) => target,
}; };
let current_v = parse_version(current).ok_or("this program's version isn't one of ours")?; 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 found_root = ledger_store::root::Root::find(flags.root.as_deref()).ok();
let key = trusted_key(feed, flags.key_file.as_deref())?; let local = found_root
let manifest_bytes = fetch(feed, MAX_MANIFEST as u64)?; .as_ref()
let manifest: Manifest = serde_json::from_slice(&manifest_bytes) .and_then(|r| local_feed(&r.path().join(UPDATES)));
.map_err(|e| format!("the release manifest isn't one we can read: {e}"))?; 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(|| { let offered = parse_version(&manifest.version).ok_or_else(|| {
format!( format!(
"the manifest's version {:?} isn't one of ours", "the manifest's version {:?} isn't one of ours",
@@ -272,9 +313,14 @@ pub fn update(
.get(&platform) .get(&platform)
.ok_or_else(|| format!("release {} has no build for {platform}", manifest.version))?; .ok_or_else(|| format!("release {} has no build for {platform}", manifest.version))?;
if flags.check { if flags.check {
let ready = found_root
.as_ref()
.and_then(|r| staged_bytes(&r.path().join(UPDATES), &manifest.version))
.is_some();
println!( println!(
"wanderbooks {} is available (this is {current}). Run `{}` to install it.", "wanderbooks {} is available{} (this is {current}). Run `{}` to install it.",
manifest.version, manifest.version,
if ready { ", already downloaded" } else { "" },
prog.trim_end_matches(" --check") prog.trim_end_matches(" --check")
); );
if !manifest.notes.trim().is_empty() { 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 root = ledger_store::root::Root::find(flags.root.as_deref()).map_err(|e| e.to_string())?;
let updates = updates_dir(&root)?; 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)?; verify(&bytes, entry, &key)?;
// Verified: from here the files are ours to write. Staged, then the // Verified: from here the files are ours to write. Staged, then the
@@ -313,6 +364,7 @@ pub fn update(
.into()); .into());
} }
let _ = std::fs::remove_dir_all(&staged_dir); let _ = std::fs::remove_dir_all(&staged_dir);
let _ = std::fs::remove_dir_all(updates.join(STAGED));
println!( println!(
"Updated wanderbooks from {current} to {}.", "Updated wanderbooks from {current} to {}.",
manifest.version manifest.version
@@ -323,6 +375,182 @@ pub fn update(
Ok(()) Ok(())
} }
/// The manifest at `feed`.
fn fetch_manifest(feed: &str) -> Result<Manifest, Failure> {
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<Vec<u8>> {
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<String> {
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<Option<String>, 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<Option<String>, 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 /// `wanderbooks rollback`: go back to the version the last update
/// replaced. /// replaced.
pub fn rollback(prog: &str, args: impl IntoIterator<Item = String>) -> Result<(), Failure> { pub fn rollback(prog: &str, args: impl IntoIterator<Item = String>) -> Result<(), Failure> {
@@ -337,7 +565,7 @@ pub fn rollback(prog: &str, args: impl IntoIterator<Item = String>) -> Result<()
}; };
let root = ledger_store::root::Root::find(flags.root.as_deref()).map_err(|e| e.to_string())?; let root = ledger_store::root::Root::find(flags.root.as_deref()).map_err(|e| e.to_string())?;
let updates = updates_dir(&root)?; 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 previous = updates.join(PREVIOUS);
let meta: serde_json::Value = std::fs::read(previous.join(META)) let meta: serde_json::Value = std::fs::read(previous.join(META))
.ok() .ok()
@@ -562,6 +790,24 @@ fn private_dir(dir: &Path) -> Result<(), Failure> {
/// One update or rollback at a time. /// One update or rollback at a time.
fn take_lock(updates: &Path) -> Result<std::fs::File, Failure> { fn take_lock(updates: &Path) -> Result<std::fs::File, Failure> {
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<std::fs::File, Failure> {
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<std::fs::File, Failure> {
let file = std::fs::OpenOptions::new() let file = std::fs::OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Merged by cargo-packager into the Info.plist it generates for the
.app (P11a step 6): the keys the generated one lacks. -->
<plist version="1.0">
<dict>
<key>LSMultipleInstancesProhibited</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
</dict>
</plist>
+33
View File
@@ -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_<arch>.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"
+36
View File
@@ -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 }
+18 -4
View File
@@ -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): 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 `rollback` refuses while a book declares a format or feature the kept
version does not know; version does not know;
- an AppImage is the exception to the restart: the host runs from the old - an AppImage runs the program from a mount that goes away with the image,
image's mount, whose path does not change, so it keeps running the old so the host learns the image's own path from `APPIMAGE` (the one variable
version until the books are next closed and opened (docs/packaging.md). `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 `<root>/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 `<root>/updates/feed` (a `file://` address, then
the key file), the same rule as `--trusted-key`.
`deploy/packaging/` holds the Linux packaging: `packager.toml` `deploy/packaging/` holds the Linux packaging: `packager.toml`
(cargo-packager: `.deb`, AppImage, the pacman tarball with its PKGBUILD), (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`, package, which repacks the release's tarball), `build-linux.sh`,
`repo-indexes.sh` (the apt and dnf indexes), `local-release.sh` (a `repo-indexes.sh` (the apt and dnf indexes), `local-release.sh` (a
throwaway-keyed feed on this machine) and `test-linux-packages.sh`. 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) ## The result envelope and the guards (`ledger-mcp`, P5)
+50 -16
View File
@@ -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) | | `.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) | | 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) | | 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) | | `.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`) | (trial) needs Windows, and native Windows is unit P11c (D-62) | `wanderbooks update` | the Windows identity (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`, `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 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 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 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`. `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 7. **The AppImage updates itself, and (since step 4) the books it holds
onto the new image.** `update` replaces `$APPIMAGE`, and an AppImage run restart onto the new image.** `update` replaces `$APPIMAGE`. The host
from a shell (`Wanderbooks.AppImage update`, or the one that was running) that is open used to run from the old image's mount
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
(`/tmp/.mount_*/usr/bin/wanderbooks`), a path that does not change, so (`/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 it never saw a replaced file. The host now reads `APPIMAGE` (it is in
version until they are closed and opened again (`wanderbooks close`). the allow-list of `start.rs`'s scrubbed environment, only when it names
Fixing it means the host learning the image's path (its environment is an absolute file; a path the host reads about itself and never tells an
scrubbed on purpose; `APPIMAGE` would join the allow-list of `start.rs`, agent), runs and restarts from that file, and tells a replaced program
or the path would come from the mount table) and starting its successor by its inode, size and last write. Tested without FUSE by
from it: a decision about what the host may trust, left to the EM (the `a_replaced_appimage_restarts_the_books` (the programs run from one
unit's report names it). Package installs and a hand-placed executable copy while `APPIMAGE` names another, as a mount does); not run against
restart as designed (`bridge_survives_update`). a real image mount in this wave.
8. **Onboarding with no terminal does not exist yet.** The unit's Done-when 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 (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 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 *downgrade* (the repositories keep every release by construction), and the
`.rpm` and `.deb` under their real signatures (step 5). `.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
`<root>/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
`<root>/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 ## What `update` and `rollback` need from a release
`manifest.json` at `releases/latest/download/` (D-26): `manifest.json` at `releases/latest/download/` (D-26):
+19
View File
@@ -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 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`). `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`, Unit tests in `update.rs`: `versions_are_three_numbers_and_order_by_number`,
`a_package_marker_beside_the_program_makes_it_package_managed`, `a_package_marker_beside_the_program_makes_it_package_managed`,
`a_manifest_address_is_resolved_beside_the_manifest`, `a_manifest_address_is_resolved_beside_the_manifest`,
+5
View File
@@ -273,6 +273,11 @@ package-linux version="" formats="deb,appimage,pacman":
cargo build --release -p ledger-cli cargo build --release -p ledger-cli
deploy/packaging/build-linux.sh {{version}} {{formats}} 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 # A release folder on this machine signed with a throwaway key; then
# `wanderbooks update --feed file://DIR/manifest.json --trusted-key DIR/release.pub` # `wanderbooks update --feed file://DIR/manifest.json --trusted-key DIR/release.pub`
# Make a local release feed to try `wanderbooks update` against # Make a local release feed to try `wanderbooks update` against
+1 -1
View File
@@ -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`. 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. 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. 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 `<root>/updates/<version>/`, 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 PKGBUILLine truncated 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 `<root>/updates/<version>/`, 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 PKGBUILLine truncated
#### P11b: the agent dock and books homes (D-27) #### P11b: the agent dock and books homes (D-27)