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:
commit
6f948592f6
15 files changed
+843
-75
No files matched your search
@@ -331,7 +331,9 @@ 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),
|
||||
"update" => ledger_host::update::update(&prog, env!("CARGO_PKG_VERSION"), args),
|
||||
"update" => {
|
||||
ledger_host::update::update(&prog, ledger_host::update::program_version(), args)
|
||||
}
|
||||
"rollback" => ledger_host::update::rollback(&prog, args),
|
||||
_ => ledger_host::commands::books(&prog, args),
|
||||
};
|
||||
@@ -399,6 +401,7 @@ fn is_bare_launch(words: &[String], stdin_is_terminal: bool) -> bool {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
ledger_host::update::set_program_version(env!("CARGO_PKG_VERSION"));
|
||||
// A file's path may be any name the system takes: the file commands
|
||||
// get the words as given. Everything else is text.
|
||||
let given: Vec<std::ffi::OsString> = env::args_os().skip(1).collect();
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! and host.rs; the P11a tests update_is_atomic, failed_update_rolls_back,
|
||||
//! unsigned_update_refused, bridge_survives_update,
|
||||
//! update_waits_for_a_running_call, package_managed_install_refuses_update,
|
||||
//! and the rollback laws (kept version, the format gate).
|
||||
//! and the rollback laws (kept version, the format gate); and step 4's
|
||||
//! daily check, staged update and apply-at-next-start.
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
mod common;
|
||||
@@ -16,8 +17,7 @@ use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const KNOBS: &str =
|
||||
"close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300";
|
||||
const KNOBS: &str = "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300,update_check=400";
|
||||
|
||||
/// The platform key a manifest names, as the program computes it.
|
||||
fn platform() -> String {
|
||||
@@ -76,6 +76,10 @@ fn base_program() -> &'static Path {
|
||||
struct Home {
|
||||
dir: tempfile::TempDir,
|
||||
exe: PathBuf,
|
||||
/// A pretend AppImage (P11a step 4): the programs run from `exe`, as
|
||||
/// from an image's mount, and `APPIMAGE` names this other file, the
|
||||
/// one an update replaces.
|
||||
image: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Home {
|
||||
@@ -95,7 +99,22 @@ impl Home {
|
||||
std::fs::create_dir(&bin).unwrap();
|
||||
let exe = bin.join("wanderbooks");
|
||||
std::fs::copy(base_program(), &exe).unwrap();
|
||||
let home = Self { dir, exe };
|
||||
let home = Self {
|
||||
dir,
|
||||
exe,
|
||||
image: None,
|
||||
};
|
||||
home.runnable();
|
||||
home
|
||||
}
|
||||
|
||||
/// A home whose program is an AppImage: the same program twice, one
|
||||
/// copy to run (the mount's) and one the image file `APPIMAGE` names.
|
||||
fn new_image(tag: &str) -> Self {
|
||||
let mut home = Self::new(tag);
|
||||
let image = home.dir.path().join("Wanderbooks.AppImage");
|
||||
std::fs::copy(base_program(), &image).unwrap();
|
||||
home.image = Some(image);
|
||||
home.runnable();
|
||||
home
|
||||
}
|
||||
@@ -103,25 +122,35 @@ impl Home {
|
||||
/// Wait until the copy can be run (a child another test thread forked
|
||||
/// while it was written may hold it open for a moment).
|
||||
fn runnable(&self) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
match Command::new(&self.exe)
|
||||
.arg("--help")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
{
|
||||
Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => {
|
||||
assert!(Instant::now() < deadline, "the copy stayed busy");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
for exe in std::iter::once(&self.exe).chain(self.image.as_ref()) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
match Command::new(exe)
|
||||
.arg("--help")
|
||||
.env("APPIMAGE_EXTRACT_AND_RUN", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
{
|
||||
Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => {
|
||||
assert!(Instant::now() < deadline, "the copy stayed busy");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
Err(e) => panic!("the copy can't be run: {e}"),
|
||||
Ok(_) => break,
|
||||
}
|
||||
Err(e) => panic!("the copy can't be run: {e}"),
|
||||
Ok(_) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The environment of the pretend AppImage, if this is one.
|
||||
fn image_env(&self, c: &mut Command) {
|
||||
if let Some(image) = &self.image {
|
||||
c.env("APPIMAGE", image);
|
||||
}
|
||||
}
|
||||
|
||||
fn root(&self) -> PathBuf {
|
||||
self.dir.path().join(".local/share/wanderbooks")
|
||||
}
|
||||
@@ -139,11 +168,13 @@ impl Home {
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.env("HOME", self.dir.path());
|
||||
self.image_env(&mut c);
|
||||
c
|
||||
}
|
||||
|
||||
/// The program that updates replace: the image, if there is one.
|
||||
fn program(&self) -> Vec<u8> {
|
||||
std::fs::read(&self.exe).unwrap()
|
||||
std::fs::read(self.image.as_ref().unwrap_or(&self.exe)).unwrap()
|
||||
}
|
||||
|
||||
fn create(&self, id: &str) {
|
||||
@@ -180,6 +211,7 @@ impl Home {
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.env("HOME", self.dir.path())
|
||||
.stdin(Stdio::null());
|
||||
self.image_env(&mut c);
|
||||
c.output().unwrap()
|
||||
}
|
||||
|
||||
@@ -655,7 +687,11 @@ impl Drop for Agent {
|
||||
/// host and server run the new file afterwards, and the head is the one
|
||||
/// the writes made.
|
||||
fn update_under_writes(tag: &str) -> (Home, Agent, Vec<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");
|
||||
let release = Release::new();
|
||||
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()
|
||||
);
|
||||
let key = release.dir.path().join("release.pub");
|
||||
let image = home.image.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _keep = &release;
|
||||
Command::new(exe)
|
||||
.args(["update", "--root"])
|
||||
let mut c = Command::new(exe);
|
||||
c.args(["update", "--root"])
|
||||
.arg(root)
|
||||
.args(["--feed", &feed, "--trusted-key"])
|
||||
.arg(key)
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.env("HOME", dir)
|
||||
.output()
|
||||
.unwrap()
|
||||
.env("HOME", dir);
|
||||
if let Some(image) = image {
|
||||
c.env("APPIMAGE", image);
|
||||
}
|
||||
c.output().unwrap()
|
||||
})
|
||||
};
|
||||
let mut written = vec!["4000".to_string()];
|
||||
@@ -742,6 +781,39 @@ fn bridge_survives_update() {
|
||||
assert!(in_books >= written.len(), "{in_books} < {}", written.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_replaced_appimage_restarts_the_books() {
|
||||
// The programs run from one file (an image's mount) while `APPIMAGE`
|
||||
// names another (the image). Replacing the image alone must restart
|
||||
// the host onto it, with the session and every write intact.
|
||||
let home = Home::new_image("appimage");
|
||||
let image = home.image.clone().unwrap();
|
||||
home.create("acme");
|
||||
let release = Release::new();
|
||||
let next = newer(&home, "appimage");
|
||||
release.publish("9.0.0", &next, |_| {});
|
||||
let mut agent = Agent::start(&home, "acme");
|
||||
agent.add_account("4000").unwrap();
|
||||
let host_before = wait_host(&home);
|
||||
let out = home.run("update", Some(&release));
|
||||
assert!(out.status.success(), "{}", text(&out));
|
||||
assert_eq!(std::fs::read(&image).unwrap(), next);
|
||||
// The mount's copy is untouched; only the image changed.
|
||||
assert_ne!(std::fs::read(&home.exe).unwrap(), next);
|
||||
assert!(
|
||||
wait_for(60, || {
|
||||
home.pids("host").len() == 1 && home.pids("host")[0] != host_before
|
||||
}),
|
||||
"the host never restarted onto the replaced image"
|
||||
);
|
||||
let host = wait_host(&home);
|
||||
assert_eq!(
|
||||
std::fs::read_link(format!("/proc/{host}/exe")).unwrap(),
|
||||
image
|
||||
);
|
||||
assert!(agent.add_account("4001").is_ok(), "the session carries on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_waits_for_a_running_call() {
|
||||
// No call is cut off or made twice: each account written across the
|
||||
@@ -763,3 +835,161 @@ fn update_waits_for_a_running_call() {
|
||||
"a write lost, or made twice, across the update"
|
||||
);
|
||||
}
|
||||
|
||||
/// Name `release` as this home's local feed, as the owner would
|
||||
/// (`<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");
|
||||
}
|
||||
Reference in new issue
Block a user