merge: main into P11c (P11a's update/rollback, the bare-launch path, the Linux-only SOCK_CLOEXEC test)
check / check (push) Canceled after 0s
check / portable (macos-latest) (push) Canceled after 0s
check / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s

Conflicts: ledger-cli main.rs (kept P11a's is_bare_launch; dropped the
"comes with P11" stubs), ledger-mcp local.rs (main's Linux gate on the
no-other-end test), docs/testing.md (both sections kept). `update` and
`rollback` are cfg(unix) in ledger-host (a running program's file cannot
be replaced on Windows), so main.rs says so on Windows (P11a step 5).

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 15:36:19 -06:00
commit 211d2dbbdb
32 files changed
+2395 -42

No files matched your search

+14
View File
@@ -45,6 +45,20 @@ serde_json = "1"
sha2 = "0.10"
tempfile = "3"
zip = "9.0.1"
minisign = "0.10.0"
[lints]
workspace = true
# The .rpm (P11a step 3), built by cargo-generate-rpm from the release
# program: `just package-linux`. Paths are relative to the workspace root.
[package.metadata.generate-rpm]
name = "wanderbooks"
summary = "Double-entry bookkeeping your agent keeps and you can read"
license = "MIT OR Apache-2.0"
url = "https://wanderbooks.ai"
assets = [
{ source = "target/release/wanderbooks", dest = "/usr/bin/wanderbooks", mode = "0755" },
{ source = "deploy/packaging/install-method-rpm", dest = "/usr/share/wanderbooks/install-method", mode = "0644" },
{ source = "deploy/packaging/wanderbooks-static.desktop", dest = "/usr/share/applications/wanderbooks.desktop", mode = "0644" },
]
+44 -2
View File
@@ -28,6 +28,7 @@ fn print_usage() {
eprintln!(" wanderbooks upload --book ID [--token NAME] UPLOAD [--] FILE");
eprintln!(" wanderbooks download --book ID [--token NAME] ATTACHMENT --to FOLDER");
eprintln!(" wanderbooks serve | close | doctor");
eprintln!(" wanderbooks update [--check] | rollback");
eprintln!();
eprintln!(" setup make the books' private folder (only your account can open it)");
eprintln!(" gui open the live, read-only view of the books (a book of the data root)");
@@ -43,6 +44,7 @@ fn print_usage() {
eprintln!(" serve hold the books open until Ctrl-C");
eprintln!(" close close the books now: everything holding them lets go");
eprintln!(" doctor whether the books are open, what holds them, what went wrong");
eprintln!(" update bring this program to the newest release (`rollback` goes back)");
}
/// The viewer's arguments for `wanderbooks gui ARGS` (plan 3.6): a lone
@@ -320,6 +322,17 @@ fn run_local(mode: &str, args: Vec<String>) -> i32 {
"token" => ledger_host::commands::token(&prog, args),
"connect" => ledger_host::commands::connect(&prog, args),
"passphrase" => ledger_host::commands::passphrase(&prog, args),
#[cfg(unix)]
"update" => ledger_host::update::update(&prog, env!("CARGO_PKG_VERSION"), args),
#[cfg(unix)]
"rollback" => ledger_host::update::rollback(&prog, args),
// Updates replace a running program's file in place, which Windows
// forbids: its updater is P11a step 5.
#[cfg(not(unix))]
"update" | "rollback" => {
eprintln!("error: `{mode}` is not built for Windows yet (P11a step 5)");
return 2;
}
_ => ledger_host::commands::books(&prog, args),
};
match result {
@@ -354,6 +367,15 @@ fn run_files(mode: &str, args: Vec<std::ffi::OsString>) -> i32 {
}
}
/// Whether the program was launched with nothing to say and no terminal
/// to say it to: a menu entry, a double-click, an AppImage (whose launcher
/// runs the desktop file's command and appends what it was given, so the
/// desktop file says plain `wanderbooks`). At a terminal it is the usage,
/// as ever.
fn is_bare_launch(words: &[String], stdin_is_terminal: bool) -> bool {
words.is_empty() && !stdin_is_terminal
}
fn main() {
// A file's path may be any name the system takes: the file commands
// get the words as given. Everything else is text.
@@ -370,6 +392,10 @@ fn main() {
eprintln!("error: an argument isn't UTF-8 text");
std::process::exit(2);
};
// A bare launch is the viewer.
if is_bare_launch(&words, std::io::IsTerminal::is_terminal(&std::io::stdin())) {
std::process::exit(run_gui(Vec::new()));
}
// The modes run in this process and take their own flags.
let mut all = words.clone().into_iter();
match all.next().as_deref() {
@@ -378,7 +404,7 @@ fn main() {
Some("server") => std::process::exit(run_server(all.collect())),
Some(
mode @ ("host" | "mcp" | "serve" | "close" | "books" | "doctor" | "token" | "connect"
| "passphrase"),
| "passphrase" | "update" | "rollback"),
) => {
let mode = mode.to_string();
std::process::exit(run_local(&mode, all.collect()))
@@ -404,7 +430,8 @@ fn main() {
{
Some(
mode @ ("gui" | "setup" | "server" | "host" | "mcp" | "serve" | "connect" | "close"
| "books" | "doctor" | "token" | "passphrase" | "upload" | "download"),
| "books" | "doctor" | "token" | "passphrase" | "update" | "rollback"
| "upload" | "download"),
) => {
eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]");
std::process::exit(2);
@@ -443,3 +470,18 @@ mod gui_args_tests {
}
}
}
#[cfg(test)]
mod bare_launch_tests {
use super::is_bare_launch;
#[test]
fn only_nothing_at_all_with_no_terminal_is_the_viewer() {
assert!(is_bare_launch(&[], false));
// At a terminal it is the usage, as ever.
assert!(!is_bare_launch(&[], true));
// Anything said is a mode or a mistake, never the viewer.
assert!(!is_bare_launch(&["--help".to_string()], false));
assert!(!is_bare_launch(&["update".to_string()], false));
}
}
+765
View File
@@ -0,0 +1,765 @@
//! `wanderbooks update` and `wanderbooks rollback` (plan P11a step 2, 3.9,
//! D-24, D-26), against a local release folder (a `file://` feed) and a
//! key the test makes. Oracles: the INVARIANTS of ledger-host's update.rs
//! 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).
#![cfg(target_os = "linux")]
mod common;
use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
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";
/// The platform key a manifest names, as the program computes it.
fn platform() -> String {
format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH)
}
fn sha256(bytes: &[u8]) -> String {
ledger_store::journal::sha256_hex(bytes)
}
fn wait_for(secs: u64, mut done: impl FnMut() -> bool) -> bool {
let deadline = Instant::now() + Duration::from_secs(secs);
while Instant::now() < deadline {
if done() {
return true;
}
std::thread::sleep(Duration::from_millis(20));
}
done()
}
/// The program the tests run, stripped once: a debug build is hundreds of
/// megabytes, and every home holds a copy that is hashed and signed.
fn base_program() -> &'static Path {
static BASE: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
BASE.get_or_init(|| {
// Named for the build, so a run after another reuses it (the
// `sweep-test-tmp` recipe removes the `wb-` folders after a day).
let source = Path::new(env!("CARGO_BIN_EXE_wanderbooks"));
let meta = std::fs::metadata(source).unwrap();
let stamp = meta
.modified()
.unwrap()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let dir = std::env::temp_dir().join(format!("wb-update-base-{}-{stamp}", meta.len()));
let base = dir.join("wanderbooks");
if !base.is_file() {
let work = std::env::temp_dir().join(format!("wb-update-work-{}", std::process::id()));
std::fs::create_dir_all(&work).unwrap();
let part = work.join("wanderbooks");
std::fs::copy(source, &part).unwrap();
// No strip tool: the program as it is.
let _ = Command::new("strip").arg("--strip-all").arg(&part).status();
let _ = std::fs::create_dir_all(&dir);
std::fs::rename(&part, &base).unwrap();
let _ = std::fs::remove_dir_all(&work);
}
base
})
}
/// A home with a data root, and a copy of the program in `bin/` that the
/// tests update.
struct Home {
dir: tempfile::TempDir,
exe: PathBuf,
}
impl Home {
fn new(tag: &str) -> Self {
let dir = tempfile::Builder::new()
.prefix(&format!("wb-update-{tag}-"))
.tempdir()
.unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.args(["setup", "--home"])
.arg(dir.path())
.env_remove("XDG_DATA_HOME")
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let bin = dir.path().join("bin");
std::fs::create_dir(&bin).unwrap();
let exe = bin.join("wanderbooks");
std::fs::copy(base_program(), &exe).unwrap();
let home = Self { dir, exe };
home.runnable();
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));
}
Err(e) => panic!("the copy can't be run: {e}"),
Ok(_) => return,
}
}
}
fn root(&self) -> PathBuf {
self.dir.path().join(".local/share/wanderbooks")
}
fn updates(&self) -> PathBuf {
self.root().join("updates")
}
fn cmd(&self, mode: &str) -> Command {
let mut c = Command::new(&self.exe);
c.arg(mode)
.arg("--root")
.arg(self.root())
.args(["--test-knobs", KNOBS])
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", self.dir.path());
c
}
fn program(&self) -> Vec<u8> {
std::fs::read(&self.exe).unwrap()
}
fn create(&self, id: &str) {
let out = self
.cmd("books")
.args(["create", id])
.stdin(Stdio::null())
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let out = self
.cmd("token")
.args(["create", "--book", id])
.stdin(Stdio::null())
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
}
/// `wanderbooks update` (or `rollback`) against `release`.
fn run(&self, mode: &str, release: Option<&Release>) -> std::process::Output {
let mut c = Command::new(&self.exe);
c.arg(mode).arg("--root").arg(self.root());
if let Some(r) = release {
c.arg("--feed")
.arg(format!(
"file://{}",
r.dir.path().join("manifest.json").display()
))
.arg("--trusted-key")
.arg(r.dir.path().join("release.pub"));
}
c.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", self.dir.path())
.stdin(Stdio::null());
c.output().unwrap()
}
fn pids(&self, mode: &str) -> Vec<u32> {
let root = self.root().display().to_string();
let mut out = Vec::new();
for entry in std::fs::read_dir("/proc").unwrap().flatten() {
let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else {
continue;
};
let Ok(raw) = std::fs::read(entry.path().join("cmdline")) else {
continue;
};
let args: Vec<String> = raw
.split(|b| *b == 0)
.map(|a| String::from_utf8_lossy(a).into_owned())
.collect();
if args.get(1).map(String::as_str) == Some(mode) && args.contains(&root) {
out.push(pid);
}
}
out
}
}
impl Drop for Home {
fn drop(&mut self) {
let _ = self
.cmd("close")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let mut found = false;
for mode in ["serve", "mcp", "host", "server"] {
for pid in self.pids(mode) {
found = true;
// SAFETY: a process of this test's own root.
unsafe { libc::kill(pid as i32, libc::SIGKILL) };
}
}
if !found || Instant::now() > deadline {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
}
}
/// A release folder: a manifest, the program file, and the key it was
/// signed with (its public half in `release.pub`).
struct Release {
dir: tempfile::TempDir,
keys: minisign::KeyPair,
}
impl Release {
fn new() -> Self {
let dir = tempfile::Builder::new()
.prefix("wb-release-")
.tempdir()
.unwrap();
let keys = minisign::KeyPair::generate_unencrypted_keypair().unwrap();
std::fs::write(
dir.path().join("release.pub"),
keys.pk.to_box().unwrap().to_string(),
)
.unwrap();
Self { dir, keys }
}
/// The signature of `bytes` by `keys`, as the `.sig` file's text.
fn sign_with(keys: &minisign::KeyPair, bytes: &[u8]) -> String {
minisign::sign(
Some(&keys.pk),
&keys.sk,
std::io::Cursor::new(bytes),
Some("test release"),
Some("signature from a test key"),
)
.unwrap()
.to_string()
}
fn b64(text: &str) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(text)
}
/// Publish `version` with `program` as this platform's file, signed
/// by our key; `tweak` changes the manifest's entry before it's
/// written.
fn publish(&self, version: &str, program: &[u8], tweak: impl FnOnce(&mut Value)) {
let name = format!("wanderbooks-{version}");
std::fs::write(self.dir.path().join(&name), program).unwrap();
let mut entry = json!({
"url": name,
"sha256": sha256(program),
"signature": Self::b64(&Self::sign_with(&self.keys, program)),
});
tweak(&mut entry);
let manifest = json!({
"version": version,
"notes": "a test release",
"platforms": { platform(): entry },
});
std::fs::write(self.dir.path().join("manifest.json"), manifest.to_string()).unwrap();
}
}
/// The program as a newer release: the same program with a tail (an ELF
/// file runs the same with bytes after its end), so its hash differs.
fn newer(home: &Home, tag: &str) -> Vec<u8> {
let mut bytes = home.program();
bytes.extend_from_slice(format!("\nrelease {tag}\n").as_bytes());
bytes
}
fn text(out: &std::process::Output) -> String {
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
}
/// What is in `dir`, for "nothing was left behind".
fn names(dir: &Path) -> Vec<String> {
let mut v: Vec<String> = std::fs::read_dir(dir)
.map(|d| {
d.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect()
})
.unwrap_or_default();
v.sort();
v
}
#[test]
fn an_update_replaces_the_program_keeps_the_old_one_and_rollback_brings_it_back() {
let home = Home::new("basic");
let old = home.program();
let release = Release::new();
let next = newer(&home, "9.0.0");
release.publish("9.0.0", &next, |_| {});
// The newest release is this one; an older offer is refused.
release.publish("0.1.0", &old, |_| {});
let same = home.run("update", Some(&release));
assert!(
same.status.success() && text(&same).contains("newest"),
"{}",
text(&same)
);
release.publish("0.0.1", &old, |_| {});
let older = home.run("update", Some(&release));
assert!(!older.status.success(), "{}", text(&older));
assert_eq!(home.program(), old);
assert!(!home.updates().join("previous").exists());
release.publish("9.0.0", &next, |_| {});
let look = home.run("update", None);
assert!(!look.status.success(), "no key, no feed: {}", text(&look));
assert!(text(&look).contains("no release key"), "{}", text(&look));
let out = home.run("update", Some(&release));
assert!(out.status.success(), "{}", text(&out));
assert!(text(&out).contains("from 0.1.0 to 9.0.0"), "{}", text(&out));
assert_eq!(home.program(), next, "the new program is in place");
let kept = home.updates().join("previous");
assert_eq!(std::fs::read(kept.join("wanderbooks")).unwrap(), old);
let meta: Value =
serde_json::from_slice(&std::fs::read(kept.join("meta.json")).unwrap()).unwrap();
assert_eq!(meta["version"], "0.1.0");
assert!(meta["format"].as_u64().unwrap() >= 2);
// Nothing staged is left, and the program's folder holds the program
// alone.
assert_eq!(names(&home.updates()), ["previous", "update.lock"]);
assert_eq!(names(home.exe.parent().unwrap()), ["wanderbooks"]);
// Rollback: the old program is back, and nothing is kept to go back to.
let out = home.run("rollback", None);
assert!(out.status.success(), "{}", text(&out));
assert_eq!(home.program(), old);
assert!(!kept.exists());
let nothing = home.run("rollback", None);
assert!(!nothing.status.success());
assert!(
text(&nothing).contains("no earlier version"),
"{}",
text(&nothing)
);
}
#[test]
fn update_is_atomic() {
let home = Home::new("atomic");
let old = home.program();
let release = Release::new();
let next = newer(&home, "atomic");
release.publish("9.0.0", &next, |_| {});
// A reader of the program file sees the old one or the new, whole,
// all through the swap.
let (old_hash, new_hash) = (sha256(&old), sha256(&next));
let path = home.exe.clone();
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let seen = {
let stop = stop.clone();
std::thread::spawn(move || {
let mut reads = 0u32;
let mut bad = Vec::new();
while !stop.load(std::sync::atomic::Ordering::Relaxed) {
match std::fs::read(&path) {
Ok(bytes) => {
let h = sha256(&bytes);
if h != old_hash && h != new_hash {
bad.push(format!("{} bytes", bytes.len()));
}
reads += 1;
}
Err(e) => bad.push(e.to_string()),
}
}
(reads, bad)
})
};
let out = home.run("update", Some(&release));
stop.store(true, std::sync::atomic::Ordering::Relaxed);
let (reads, bad) = seen.join().unwrap();
assert!(out.status.success(), "{}", text(&out));
assert!(reads > 0);
assert!(bad.is_empty(), "a reader saw a part of a program: {bad:?}");
assert_eq!(home.program(), next);
}
#[test]
fn failed_update_rolls_back() {
let home = Home::new("failed");
let old = home.program();
let release = Release::new();
// Validly signed, but not a program that starts.
release.publish("9.0.0", b"#!/bin/sh\nexit 3\n", |_| {});
let out = home.run("update", Some(&release));
assert!(!out.status.success(), "{}", text(&out));
assert!(text(&out).contains("didn't start"), "{}", text(&out));
assert_eq!(home.program(), old, "the old program is back");
assert_eq!(names(home.exe.parent().unwrap()), ["wanderbooks"]);
// And it still works.
home.runnable();
// The failed one is not left staged to be tried again.
assert!(!home.updates().join("9.0.0").exists());
}
#[test]
fn unsigned_update_refused() {
let home = Home::new("unsigned");
let old = home.program();
let release = Release::new();
let next = newer(&home, "unsigned");
let other = minisign::KeyPair::generate_unencrypted_keypair().unwrap();
type Tweak = Box<dyn FnOnce(&mut Value)>;
let mut cases: Vec<(&str, Tweak)> = Vec::new();
// A bad signature: a good one by another key.
let by_other = Release::b64(&Release::sign_with(&other, &next));
cases.push((
"another key",
Box::new(move |e| e["signature"] = json!(by_other)),
));
// A signature of other bytes.
let of_other_bytes = Release::b64(&Release::sign_with(&release.keys, b"something else"));
cases.push((
"other bytes",
Box::new(move |e| e["signature"] = json!(of_other_bytes)),
));
// A sha that differs from the manifest's.
cases.push(("sha256", Box::new(|e| e["sha256"] = json!("0".repeat(64)))));
cases.push(("no signature", Box::new(|e| e["signature"] = json!(""))));
cases.push((
"not a signature",
Box::new(|e| e["signature"] = json!("AAAA")),
));
for (name, tweak) in cases {
release.publish("9.0.0", &next, tweak);
let out = home.run("update", Some(&release));
assert!(!out.status.success(), "{name}: {}", text(&out));
assert!(text(&out).contains("refused"), "{name}: {}", text(&out));
assert_eq!(home.program(), old, "{name}: the program changed");
assert!(!home.updates().join("previous").exists(), "{name}: kept");
assert!(!home.updates().join("9.0.0").exists(), "{name}: staged");
assert_eq!(names(home.exe.parent().unwrap()), ["wanderbooks"], "{name}");
}
// The key must be named only with a local feed.
let out = Command::new(&home.exe)
.args(["update", "--root"])
.arg(home.root())
.args([
"--feed",
"https://example.invalid/manifest.json",
"--trusted-key",
])
.arg(release.dir.path().join("release.pub"))
.env_clear()
.output()
.unwrap();
assert!(!out.status.success());
assert!(text(&out).contains("file:// feed"), "{}", text(&out));
}
#[test]
fn package_managed_install_refuses_update() {
let home = Home::new("package");
let old = home.program();
let share = home.dir.path().join("share/wanderbooks");
std::fs::create_dir_all(&share).unwrap();
std::fs::write(share.join("install-method"), "deb\n").unwrap();
let release = Release::new();
release.publish("9.0.0", &newer(&home, "pkg"), |_| {});
for mode in ["update", "rollback"] {
let out = home.run(mode, Some(&release));
let out = if mode == "rollback" {
home.run(mode, None)
} else {
out
};
assert!(!out.status.success(), "{mode}: {}", text(&out));
assert!(
text(&out).contains("managed by deb"),
"{mode}: {}",
text(&out)
);
assert!(
text(&out).contains("package manager"),
"{mode}: {}",
text(&out)
);
}
assert_eq!(home.program(), old);
assert!(!home.updates().exists(), "nothing was made");
}
#[test]
fn rollback_refuses_after_a_book_consented_to_a_newer_format() {
let home = Home::new("consent");
home.create("acme");
let release = Release::new();
let next = newer(&home, "consent");
release.publish("9.0.0", &next, |_| {});
let out = home.run("update", Some(&release));
assert!(out.status.success(), "{}", text(&out));
// The version kept understood less than this one writes into new
// books (a feature it lacks).
let meta_path = home.updates().join("previous/meta.json");
let mut meta: Value = serde_json::from_slice(&std::fs::read(&meta_path).unwrap()).unwrap();
let features = meta["features"].as_array_mut().unwrap();
assert!(!features.is_empty());
features.pop();
std::fs::write(&meta_path, meta.to_string()).unwrap();
let out = home.run("rollback", None);
assert!(!out.status.success(), "{}", text(&out));
assert!(
text(&out).contains("newer journal format"),
"{}",
text(&out)
);
assert_eq!(home.program(), next, "the program stayed");
assert!(home.updates().join("previous/wanderbooks").is_file());
// Declared what the kept version knows: it goes back.
let mut meta: Value = serde_json::from_slice(&std::fs::read(&meta_path).unwrap()).unwrap();
meta["features"] = json!(ledger_store::format::current().features);
std::fs::write(&meta_path, meta.to_string()).unwrap();
let out = home.run("rollback", None);
assert!(out.status.success(), "{}", text(&out));
}
/// The bridge over stdio, spoken to as an MCP client.
struct Agent {
child: std::process::Child,
lines: mpsc::Receiver<String>,
next: u64,
}
impl Agent {
fn start(home: &Home, book: &str) -> Self {
let mut c = home.cmd("mcp");
c.args(["--book", book]);
let mut child = c
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let (tx, lines) = mpsc::channel();
let out = child.stdout.take().unwrap();
std::thread::spawn(move || {
for line in BufReader::new(out).lines() {
let Ok(line) = line else { break };
if tx.send(line).is_err() {
break;
}
}
});
let mut a = Self {
child,
lines,
next: 1,
};
a.request(
"initialize",
json!({"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "test", "version": "0"}}),
);
a.send(json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
a
}
fn send(&mut self, message: Value) {
let stdin = self.child.stdin.as_mut().unwrap();
writeln!(stdin, "{message}").unwrap();
stdin.flush().unwrap();
}
fn request(&mut self, method: &str, params: Value) -> Value {
let id = self.next;
self.next += 1;
self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let left = deadline.saturating_duration_since(Instant::now());
let line = self
.lines
.recv_timeout(left)
.unwrap_or_else(|_| panic!("no answer to {method}"));
let v: Value = serde_json::from_str(&line).unwrap();
if v.get("id") == Some(&json!(id)) {
return v;
}
}
}
fn call(&mut self, tool: &str, args: Value) -> Result<Value, String> {
let r = common::tools_call(|m, p| self.request(m, p), tool, args);
if let Some(e) = r.get("error") {
return Err(e["message"].as_str().unwrap_or_default().to_string());
}
if r["result"]["isError"] == true {
return Err(r["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default()
.into());
}
Ok(r["result"]["structuredContent"].clone())
}
fn add_account(&mut self, code: &str) -> Result<Value, String> {
self.call(
"books.add_account",
json!({"code": code, "name": format!("A{code}"), "account_type": "expense"}),
)
}
}
impl Drop for Agent {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// Update while an agent's session writes without pause: every write is
/// answered and made once, the session carries on with no reconnect, the
/// 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<String>) {
let home = Home::new(tag);
home.create("acme");
let release = Release::new();
let next = newer(&home, tag);
release.publish("9.0.0", &next, |_| {});
let mut agent = Agent::start(&home, "acme");
agent.add_account("4000").unwrap();
let before = agent.request("tools/list", json!({}));
let host_before = wait_host(&home);
let updating = {
let exe = home.exe.clone();
let (root, dir) = (home.root(), home.dir.path().to_path_buf());
let feed = format!(
"file://{}",
release.dir.path().join("manifest.json").display()
);
let key = release.dir.path().join("release.pub");
std::thread::spawn(move || {
let _keep = &release;
Command::new(exe)
.args(["update", "--root"])
.arg(root)
.args(["--feed", &feed, "--trusted-key"])
.arg(key)
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", dir)
.output()
.unwrap()
})
};
let mut written = vec!["4000".to_string()];
let mut k = 0u32;
// Writes all through the update and the restart it causes.
let deadline = Instant::now() + Duration::from_secs(90);
let mut restarted = false;
while !restarted {
assert!(
Instant::now() < deadline,
"the host never restarted onto the update"
);
let code = format!("{}", 5000 + k);
k += 1;
let r = agent.add_account(&code);
assert!(r.is_ok(), "a write across the update: {r:?}");
written.push(code);
if updating.is_finished() {
restarted = wait_for(1, || {
home.pids("host").len() == 1 && home.pids("host")[0] != host_before
});
}
}
let out = updating.join().unwrap();
assert!(out.status.success(), "{}", text(&out));
assert_eq!(home.program(), next);
// The host and the server run the program now on disk.
let host = wait_host(&home);
assert_ne!(host, host_before);
let exe = |pid: u32| std::fs::read_link(format!("/proc/{pid}/exe")).unwrap();
assert!(wait_for(20, || !home.pids("server").is_empty()));
assert_eq!(exe(home.pids("server")[0]), exe(host));
assert!(!exe(host).to_string_lossy().contains("(deleted)"));
// The session carries on, its tools unchanged.
assert!(agent.add_account("4999").is_ok());
written.push("4999".into());
let after = agent.request("tools/list", json!({}));
assert_eq!(before["result"], after["result"]);
(home, agent, written)
}
fn wait_host(home: &Home) -> u32 {
assert!(
wait_for(20, || home.pids("host").len() == 1),
"no single host"
);
home.pids("host")[0]
}
#[test]
fn bridge_survives_update() {
let (_home, mut agent, written) = update_under_writes("bridge");
let chart = agent.call("books.chart", json!({})).unwrap();
let in_books = chart["accounts"].as_array().unwrap().len();
assert!(in_books >= written.len(), "{in_books} < {}", written.len());
}
#[test]
fn update_waits_for_a_running_call() {
// No call is cut off or made twice: each account written across the
// update is in the books exactly once.
let (_home, mut agent, written) = update_under_writes("running");
let chart = agent.call("books.chart", json!({})).unwrap();
let mut in_books: Vec<String> = chart["accounts"]
.as_array()
.unwrap()
.iter()
.filter_map(|a| a["code"].as_str().map(str::to_string))
.filter(|c| c.starts_with('4') || c.starts_with('5'))
.collect();
let mut written = written;
in_books.sort();
written.sort();
assert_eq!(
in_books, written,
"a write lost, or made twice, across the update"
);
}
+1
View File
@@ -22,6 +22,7 @@ tokio-util = { version = "0.7", features = ["rt"] }
rpassword = "7"
# Token texts and passphrases are wiped from memory when dropped.
zeroize = "1"
minisign-verify = "0.3.0"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+2
View File
@@ -20,6 +20,8 @@ pub mod lease;
pub mod opener;
mod start;
pub mod tokens;
#[cfg(unix)]
pub mod update;
/// How a mode stops when it can't go on: the reason is already printed,
/// and the number is the process's exit code.
+774
View File
@@ -0,0 +1,774 @@
//! `wanderbooks update` and `wanderbooks rollback` (plan P11a step 2,
//! 3.9, D-24, D-26).
//!
//! The owner's way to a newer version of the program, for an install that
//! updates itself (an AppImage, or the executable put somewhere by hand).
//! A package-managed install refuses and says to use the package manager.
//!
//! ```text
//! GOAL: a newer version replaces the running program only if it is the
//! one we released, whole, and the books never see a half-written
//! or unverified program.
//! SOURCE: plan/served.md 3.9 (Restarting; Format changes are the
//! server's gate, not the updater's), D-24 (every self-update is
//! verified against our own release keys), D-26 (the feed is a
//! manifest at a fixed address).
//! INVARIANTS:
//! 1. The manifest names a version, and for this platform a file's
//! address, its sha256 and its minisign signature. An update goes
//! ahead only if the version is higher than this one's, the bytes
//! downloaded have that sha256 AND verify against the release key
//! this program carries; anything else is refused before a byte is
//! written (nothing staged, nothing swapped).
//! 2. The program file is replaced by a rename of a complete file made
//! in its own folder: a reader sees the old file or the new, never a
//! part.
//! 3. The version replaced is kept under `<root>/updates/previous/` with
//! the journal format it understood, until a rollback uses it or the
//! next update replaces it.
//! 4. The new program must answer `--help` once swapped in; if not, the
//! old one is put back and the update is an error.
//! 5. The running books are never told to stop: the host notices its
//! file was replaced and restarts as it does for a package upgrade
//! (host.rs INVARIANT 7); calls in progress finish first.
//! 6. A rollback refuses while any book declares a journal format or
//! feature the kept version doesn't know (the owner consented to a
//! 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.
//! SCENARIOS:
//! 1. IF the feed offers a higher version, signed by our key: SHOULD
//! replace the program and keep the old one.
//! 2. IF the file's sha256 differs from the manifest's, or its signature
//! 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.
//! ```
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::io::Write;
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
type Failure = Box<dyn std::error::Error>;
/// Where the release manifest is (D-26): the fixed address GitHub
/// Releases serves the newest release's files at.
pub const DEFAULT_FEED: &str =
"https://github.com/swaits/wanderbooks/releases/latest/download/manifest.json";
/// The release public key (minisign, base64) this program trusts, put in
/// at build time by the release workflow (P11a step 5). A build without
/// one trusts nothing.
const RELEASE_KEY: Option<&str> = option_env!("WANDERBOOKS_RELEASE_PUBKEY");
/// The updates folder in the data root, and what it holds.
const UPDATES: &str = "updates";
const PREVIOUS: &str = "previous";
const PROGRAM: &str = "wanderbooks";
const META: &str = "meta.json";
const MAX_MANIFEST: usize = 1 << 20;
const MAX_PROGRAM: u64 = 1 << 30;
/// How long the new program has to answer `--help`.
const HEALTH_WAIT: Duration = Duration::from_secs(30);
/// The release manifest (the shape `cargo-packager-updater` reads: a
/// version and a file per platform, its signature the base64 of the
/// `.sig` file), with the file's sha256 added.
#[derive(Debug, Deserialize)]
struct Manifest {
version: String,
#[serde(default)]
notes: String,
platforms: BTreeMap<String, Entry>,
}
#[derive(Debug, Deserialize)]
struct Entry {
/// The file's address: absolute, or relative to the manifest's.
url: String,
sha256: String,
/// The minisign signature file's text, or its base64.
signature: String,
}
/// How this program was installed.
#[derive(Debug, PartialEq, Eq)]
pub enum Install {
/// A file this program may replace: the AppImage, or the executable.
SelfUpdating(PathBuf),
/// A package manager owns the file (`deb`, `rpm`, `aur`, ...).
PackageManaged(String),
}
/// What kind of install the executable at `exe` is. A package puts
/// `<prefix>/share/wanderbooks/install-method` beside `<prefix>/bin`, with
/// its manager's name in it; an AppImage says where it is in `APPIMAGE`.
pub fn install_kind(exe: &Path, appimage: Option<&Path>) -> Install {
if let Some(image) = appimage.filter(|p| p.is_file()) {
return Install::SelfUpdating(image.to_path_buf());
}
if let Some(prefix) = exe.parent().and_then(Path::parent)
&& let Ok(text) = std::fs::read_to_string(prefix.join("share/wanderbooks/install-method"))
{
let manager = text.split_whitespace().next().unwrap_or("package");
return Install::PackageManaged(manager.to_string());
}
Install::SelfUpdating(exe.to_path_buf())
}
fn this_install() -> Result<Install, String> {
let exe = crate::start::this_executable()?;
let image = std::env::var_os("APPIMAGE").map(PathBuf::from);
Ok(install_kind(&exe, image.as_deref()))
}
fn refuse_package_managed(manager: &str, what: &str) -> Failure {
format!(
"this install is managed by {manager}, which also {what} it: use the package manager (our apt and dnf repositories keep every release, and the AUR keeps old PKGBUILDs)"
)
.into()
}
/// `1.2.3` as numbers; anything else (a pre-release, a `v`) is no version
/// of ours.
pub fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
let mut parts = text.split('.');
let mut next = || -> Option<u64> {
let p = parts.next()?;
(!p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
.then(|| p.parse().ok())
.flatten()
};
let v = (next()?, next()?, next()?);
parts.next().is_none().then_some(v)
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn sha256_hex(bytes: &[u8]) -> String {
hex(&Sha256::digest(bytes))
}
/// The flags of `update`.
#[derive(Default)]
struct Flags {
root: Option<PathBuf>,
feed: Option<String>,
key_file: Option<PathBuf>,
check: bool,
}
fn parse_flags(
prog: &str,
args: impl IntoIterator<Item = String>,
update: bool,
) -> Result<Option<Flags>, Failure> {
let mut flags = Flags::default();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
match arg.as_str() {
"--root" => {
flags.root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?))
}
"--feed" if update => flags.feed = Some(args.next().ok_or("--feed needs an address")?),
"--trusted-key" if update => {
flags.key_file = Some(PathBuf::from(
args.next().ok_or("--trusted-key needs a file")?,
))
}
"--check" if update => flags.check = true,
"--help" | "-h" => {
if update {
eprintln!("usage: {prog} [--check] [--feed ADDRESS [--trusted-key FILE]]");
eprintln!(
" Brings this program to the newest release: downloads it, checks it"
);
eprintln!(
" against our release key, keeps the version it replaces, and swaps"
);
eprintln!(" it in; the books restart onto it when idle. --check only looks.");
eprintln!(
" --feed reads a manifest from elsewhere (a file:// folder, to update"
);
eprintln!(
" from a local release); only then may --trusted-key name the minisign"
);
eprintln!(" public key to check against.");
} else {
eprintln!("usage: {prog}");
eprintln!(" Goes back to the version the last update replaced, unless a book");
eprintln!(" has consented to a newer journal format that version can't open.");
}
return Ok(None);
}
other => {
eprintln!("unknown argument: {other}");
eprintln!(
"usage: {prog} {}",
if update {
"[--check] [--feed ADDRESS [--trusted-key FILE]]"
} else {
""
}
);
return Err(Box::new(crate::Exit(2)));
}
}
}
Ok(Some(flags))
}
/// `wanderbooks update`: bring this program to the newest release.
/// `current` is this program's version.
pub fn update(
prog: &str,
current: &str,
args: impl IntoIterator<Item = String>,
) -> Result<(), Failure> {
let Some(flags) = parse_flags(prog, args, true)? else {
return Ok(());
};
let target = match this_install()? {
Install::PackageManaged(manager) => {
return Err(refuse_package_managed(&manager, "updates"));
}
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 offered = parse_version(&manifest.version).ok_or_else(|| {
format!(
"the manifest's version {:?} isn't one of ours",
manifest.version
)
})?;
if offered == current_v {
println!("wanderbooks {current} is the newest release.");
return Ok(());
}
if offered < current_v {
return Err(format!(
"the feed offers {}, older than this {current}: not installing it (to go back, `wanderbooks rollback`)",
manifest.version
)
.into());
}
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))?;
if flags.check {
println!(
"wanderbooks {} is available (this is {current}). Run `{}` to install it.",
manifest.version,
prog.trim_end_matches(" --check")
);
if !manifest.notes.trim().is_empty() {
println!("{}", manifest.notes.trim());
}
return Ok(());
}
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 bytes = fetch(&resolve(feed, &entry.url)?, MAX_PROGRAM)?;
verify(&bytes, entry, &key)?;
// Verified: from here the files are ours to write. Staged, then the
// old one kept, then swapped in.
let staged_dir = updates.join(&manifest.version);
private_dir(&staged_dir)?;
let staged = staged_dir.join(PROGRAM);
write_program(&staged, &bytes)?;
keep_previous(&updates, &target, current)?;
swap_in(&target, &staged)?;
if let Err(why) = healthy(&target) {
// INVARIANT 4: put the old one back.
let old = updates.join(PREVIOUS).join(PROGRAM);
let back = swap_in(&target, &old);
let _ = std::fs::remove_dir_all(&staged_dir);
return Err(match back {
Ok(()) => format!(
"the new version didn't start ({why}); the old one is back and nothing changed"
),
Err(e) => format!(
"the new version didn't start ({why}) and the old one couldn't be put back ({e}); it is kept in the updates folder"
),
}
.into());
}
let _ = std::fs::remove_dir_all(&staged_dir);
println!(
"Updated wanderbooks from {current} to {}.",
manifest.version
);
println!(
"The books that are open restart onto it by themselves when they are idle; nothing in progress is cut off. `wanderbooks rollback` goes back."
);
Ok(())
}
/// `wanderbooks rollback`: go back to the version the last update
/// replaced.
pub fn rollback(prog: &str, args: impl IntoIterator<Item = String>) -> Result<(), Failure> {
let Some(flags) = parse_flags(prog, args, false)? else {
return Ok(());
};
let target = match this_install()? {
Install::PackageManaged(manager) => {
return Err(refuse_package_managed(&manager, "downgrades"));
}
Install::SelfUpdating(target) => target,
};
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 previous = updates.join(PREVIOUS);
let meta: serde_json::Value = std::fs::read(previous.join(META))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.ok_or("there is no earlier version kept to go back to")?;
if !previous.join(PROGRAM).is_file() {
return Err("there is no earlier version kept to go back to".into());
}
let version = meta["version"].as_str().unwrap_or("the earlier version");
let known = ledger_store::format::JournalFormat {
format: meta["format"].as_u64().unwrap_or(0) as u32,
features: meta["features"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|f| f.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
};
refuse_if_consented(&root, version, &known)?;
// The newer program is set aside while the older one is tried.
let aside = updates.join("rolling-back");
let newer = std::fs::read(&target)
.map_err(|e| format!("can't read this program to set it aside: {}", e.kind()))?;
write_program(&aside, &newer)?;
drop(newer);
swap_in(&target, &previous.join(PROGRAM))?;
if let Err(why) = healthy(&target) {
let back = swap_in(&target, &aside);
let _ = std::fs::remove_file(&aside);
return Err(match back {
Ok(()) => format!("version {version} didn't start ({why}); the newer one is back and nothing changed"),
Err(e) => format!("version {version} didn't start ({why}) and the newer one couldn't be put back ({e})"),
}
.into());
}
let _ = std::fs::remove_file(&aside);
let _ = std::fs::remove_dir_all(&previous);
println!("Went back to wanderbooks {version}.");
println!("The books that are open restart onto it by themselves when they are idle.");
Ok(())
}
/// INVARIANT 6: refuse while a book declares more than `known`.
fn refuse_if_consented(
root: &ledger_store::root::Root,
version: &str,
known: &ledger_store::format::JournalFormat,
) -> Result<(), Failure> {
for id in root.list_books().map_err(|e| e.to_string())? {
let journal = root.book_dir(&id).join(ledger_store::JOURNAL_FILE);
let declared = match ledger_store::journal::read_journal_and_format(&journal) {
Ok((_, declared)) => declared,
Err(e) => {
return Err(format!(
"can't tell what format the books {id} are in ({e}), so not going back"
)
.into());
}
};
let newer_format = declared.format > known.format;
let newer: Vec<&String> = declared
.features
.iter()
.filter(|f| !known.features.contains(f))
.collect();
if newer_format || !newer.is_empty() {
return Err(format!(
"the books {id} were moved to a newer journal format, which version {version} can't open: going back would leave them unreadable. The only way back is restoring the backup made before the upgrade (it says how many entries would be lost)"
)
.into());
}
}
Ok(())
}
/// The key to check against: the one compiled in, or, for a `file://`
/// feed only, the one the owner names (INVARIANT 8).
fn trusted_key(feed: &str, key_file: Option<&Path>) -> Result<minisign_verify::PublicKey, Failure> {
let text = match (key_file, RELEASE_KEY) {
(Some(file), _) => {
if !feed.starts_with("file://") {
return Err("--trusted-key goes with a file:// feed only: the key a release is checked against is the one built into this program".into());
}
std::fs::read_to_string(file).map_err(|e| format!("can't read the key: {e}"))?
}
(None, Some(key)) => key.to_string(),
(None, None) => {
return Err("this build carries no release key, so it can't check an update: install a release build, or update from a local release with --feed file://… --trusted-key FILE".into());
}
};
parse_public_key(&text)
.map_err(|e| format!("the release key isn't a minisign public key: {e}").into())
}
/// A minisign public key as `minisign -G` writes it (two lines), as
/// `cargo packager signer generate` writes it (those lines in base64), or
/// as the bare key line.
pub fn parse_public_key(text: &str) -> Result<minisign_verify::PublicKey, minisign_verify::Error> {
use base64::Engine;
let text = text.trim();
if text.starts_with("untrusted comment:") {
return minisign_verify::PublicKey::decode(text);
}
if let Ok(raw) = base64::engine::general_purpose::STANDARD.decode(text)
&& let Ok(inner) = String::from_utf8(raw)
&& inner.starts_with("untrusted comment:")
{
return minisign_verify::PublicKey::decode(&inner);
}
minisign_verify::PublicKey::from_base64(text)
}
/// INVARIANT 1: the sha256 AND the signature.
fn verify(bytes: &[u8], entry: &Entry, key: &minisign_verify::PublicKey) -> Result<(), Failure> {
let have = sha256_hex(bytes);
if !have.eq_ignore_ascii_case(entry.sha256.trim()) {
return Err(
"the download doesn't match the checksum in the manifest: refused, nothing changed"
.into(),
);
}
let text = entry.signature.trim();
let text = if text.starts_with("untrusted comment:") {
text.to_string()
} else {
use base64::Engine;
let raw = base64::engine::general_purpose::STANDARD
.decode(text)
.map_err(
|_| "the manifest's signature isn't a minisign signature: refused, nothing changed",
)?;
String::from_utf8(raw).map_err(|_| "the manifest's signature isn't text")?
};
let signature = minisign_verify::Signature::decode(&text).map_err(|e| {
format!(
"the manifest's signature isn't a minisign signature ({e}): refused, nothing changed"
)
})?;
key.verify(bytes, &signature, false).map_err(|e| {
Box::<dyn std::error::Error>::from(format!(
"the download isn't signed by our release key ({e}): refused, nothing changed"
))
})
}
/// The address of `url` as the manifest at `feed` means it.
fn resolve(feed: &str, url: &str) -> Result<String, Failure> {
if url.contains("://") {
return Ok(url.to_string());
}
if let Some(path) = feed.strip_prefix("file://") {
let dir = Path::new(path).parent().unwrap_or(Path::new("/"));
return Ok(format!("file://{}", dir.join(url).display()));
}
let base = reqwest::Url::parse(feed).map_err(|e| format!("the feed address is bad: {e}"))?;
Ok(base
.join(url)
.map_err(|e| format!("the manifest's file address is bad: {e}"))?
.to_string())
}
/// The bytes at `address` (`file://` or `https://`), at most `max`.
fn fetch(address: &str, max: u64) -> Result<Vec<u8>, Failure> {
if let Some(path) = address.strip_prefix("file://") {
let file = std::fs::File::open(path).map_err(|e| format!("can't read {address}: {e}"))?;
let mut bytes = Vec::new();
std::io::Read::read_to_end(&mut std::io::Read::take(file, max + 1), &mut bytes)
.map_err(|e| format!("can't read {address}: {e}"))?;
if bytes.len() as u64 > max {
return Err(format!("{address} is larger than a release can be").into());
}
return Ok(bytes);
}
if !address.starts_with("https://") {
return Err(format!("{address}: only https:// and file:// feeds are read").into());
}
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
rt.block_on(async {
let mut response = reqwest::Client::new()
.get(address)
.timeout(Duration::from_secs(600))
.send()
.await
.and_then(|r| r.error_for_status())
.map_err(|e| format!("can't fetch {address}: {}", e.without_url()))?;
let mut bytes = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|e| format!("can't fetch {address}: {}", e.without_url()))?
{
bytes.extend_from_slice(&chunk);
if bytes.len() as u64 > max {
return Err(format!("{address} is larger than a release can be"));
}
}
Ok(bytes)
})
.map_err(Into::into)
}
/// `<root>/updates`, made 0700 if missing.
fn updates_dir(root: &ledger_store::root::Root) -> Result<PathBuf, Failure> {
let dir = root.path().join(UPDATES);
private_dir(&dir)?;
Ok(dir)
}
fn private_dir(dir: &Path) -> Result<(), Failure> {
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
.map_err(|e| format!("can't make the updates folder: {}", e.kind()))?;
Ok(())
}
/// One update or rollback at a time.
fn take_lock(updates: &Path) -> Result<std::fs::File, Failure> {
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.mode(0o600)
.open(updates.join("update.lock"))
.map_err(|e| format!("can't open the update lock: {}", e.kind()))?;
match file.try_lock() {
Ok(()) => Ok(file),
Err(std::fs::TryLockError::WouldBlock) => {
Err("another update or rollback is running".into())
}
Err(std::fs::TryLockError::Error(e)) => {
Err(format!("can't take the update lock: {}", e.kind()).into())
}
}
}
/// Write a program file whole and executable, readable by its owner only
/// until the swap.
fn write_program(path: &Path, bytes: &[u8]) -> Result<(), Failure> {
let partial = path.with_extension("partial");
let _ = std::fs::remove_file(&partial);
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o700)
.open(&partial)
.map_err(|e| format!("can't stage the update: {}", e.kind()))?;
file.write_all(bytes)
.and_then(|()| file.sync_all())
.map_err(|e| format!("can't stage the update: {}", e.kind()))?;
drop(file);
std::fs::rename(&partial, path).map_err(|e| format!("can't stage the update: {}", e.kind()))?;
Ok(())
}
/// INVARIANT 3: the program about to be replaced, with what it understood
/// of the journal.
fn keep_previous(updates: &Path, target: &Path, version: &str) -> Result<(), Failure> {
let previous = updates.join(PREVIOUS);
let _ = std::fs::remove_dir_all(&previous);
private_dir(&previous)?;
let bytes = std::fs::read(target)
.map_err(|e| format!("can't read this program to keep it: {}", e.kind()))?;
write_program(&previous.join(PROGRAM), &bytes)?;
let known = ledger_store::format::current();
let meta = serde_json::json!({
"version": version,
"format": known.format,
"features": known.features,
});
std::fs::write(previous.join(META), meta.to_string())
.map_err(|e| format!("can't keep this program: {}", e.kind()))?;
Ok(())
}
/// INVARIANT 2: replace `target` with a copy of `from`, by renaming a
/// complete file made beside it.
fn swap_in(target: &Path, from: &Path) -> Result<(), Failure> {
let dir = target.parent().ok_or("this program has no folder")?;
let name = target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let temp = dir.join(format!(".{name}.update-{}", std::process::id()));
let result = (|| -> std::io::Result<()> {
let _ = std::fs::remove_file(&temp);
let mut source = std::fs::File::open(from)?;
let mut out = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o755)
.open(&temp)?;
std::io::copy(&mut source, &mut out)?;
out.sync_all()?;
drop(out);
std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o755))?;
std::fs::rename(&temp, target)
})();
if let Err(e) = result {
let _ = std::fs::remove_file(&temp);
return Err(format!(
"can't replace the program ({}): is its folder yours to write? Nothing was changed",
e.kind()
)
.into());
}
Ok(())
}
/// INVARIANT 4: the program at `path` starts and answers `--help`.
fn healthy(path: &Path) -> Result<(), String> {
let deadline = Instant::now() + HEALTH_WAIT;
let mut child = loop {
match Command::new(path)
.arg("--help")
.env("APPIMAGE_EXTRACT_AND_RUN", "1")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(child) => break child,
// The file was just written; a process forked meanwhile may
// still hold it open for writing.
Err(e)
if e.kind() == std::io::ErrorKind::ExecutableFileBusy
&& Instant::now() < deadline =>
{
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => return Err(format!("can't run it: {}", e.kind())),
}
};
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => return Ok(()),
Ok(Some(status)) => return Err(format!("it exited with {status}")),
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)),
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err("it didn't answer in time".into());
}
Err(e) => return Err(e.to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn versions_are_three_numbers_and_order_by_number() {
assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
assert!(parse_version("0.10.0") > parse_version("0.9.9"));
for bad in ["", "1.2", "1.2.3.4", "v1.2.3", "1.2.3-rc1", "1..3", "1.2.x"] {
assert_eq!(parse_version(bad), None, "{bad}");
}
}
#[test]
fn a_package_marker_beside_the_program_makes_it_package_managed() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
let exe = bin.join("wanderbooks");
std::fs::write(&exe, b"x").unwrap();
assert_eq!(install_kind(&exe, None), Install::SelfUpdating(exe.clone()));
let share = dir.path().join("share/wanderbooks");
std::fs::create_dir_all(&share).unwrap();
std::fs::write(share.join("install-method"), "deb\n").unwrap();
assert_eq!(
install_kind(&exe, None),
Install::PackageManaged("deb".into())
);
// An AppImage is its own file, whatever is beside the mount.
let image = dir.path().join("Wanderbooks.AppImage");
std::fs::write(&image, b"x").unwrap();
assert_eq!(
install_kind(&exe, Some(&image)),
Install::SelfUpdating(image)
);
}
#[test]
fn a_public_key_is_read_in_every_form_minisign_and_the_packager_write_it() {
let bare = "RWQdrs7W5/jf6drdD0G/ymxFmRWYAIOyggYlubz4NQzdQm5e/Fj0nrb0";
let two = format!("untrusted comment: minisign public key: E9DFF8E7D6CEAE1D\n{bare}\n");
use base64::Engine;
let wrapped = base64::engine::general_purpose::STANDARD.encode(&two);
for form in [bare.to_string(), two, wrapped] {
parse_public_key(&form).unwrap_or_else(|e| panic!("{form}: {e}"));
}
assert!(parse_public_key("not a key").is_err());
}
#[test]
fn a_manifest_address_is_resolved_beside_the_manifest() {
assert_eq!(
resolve("file:///rel/manifest.json", "wb").unwrap(),
"file:///rel/wb"
);
assert_eq!(
resolve("https://h.example/a/b/manifest.json", "wb").unwrap(),
"https://h.example/a/b/wb"
);
assert_eq!(
resolve("file:///x/m.json", "https://o/wb").unwrap(),
"https://o/wb"
);
}
#[test]
fn a_swap_leaves_the_whole_new_file_and_no_temporary() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("wanderbooks");
let from = dir.path().join("new");
std::fs::write(&target, b"old").unwrap();
std::fs::write(&from, b"new").unwrap();
swap_in(&target, &from).unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"new");
let names: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(names.len(), 2, "{names:?}");
}
}
+3 -2
View File
@@ -2548,8 +2548,9 @@ mod tests {
/// A socket with no other end: the system answers for it (Linux: no
/// process, no user), and that answer names neither this process nor
/// this account; the client's check refuses it.
#[cfg(unix)]
/// this account; the client's check refuses it. Linux only: the
/// answer and `SOCK_CLOEXEC` are Linux's (the portable job found it).
#[cfg(target_os = "linux")]
#[test]
fn a_socket_with_no_other_end_names_no_process_and_isnt_us() {
use std::os::fd::FromRawFd;