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
@@ -3,8 +3,11 @@
|
||||
# program linked on a newer one does not start on an older one), attach
|
||||
# them to the GitHub release, and add them to the apt and dnf repositories
|
||||
# on the gh-pages branch (GitHub Pages serves them). Unsigned: step 5 of
|
||||
# the unit (the release key, Q-4) signs the manifest and the indexes, and
|
||||
# step 6 adds the macOS and Windows jobs. The release holds no key today.
|
||||
# the unit (the release key, Q-4) signs the manifest and the indexes. The
|
||||
# macOS job builds the .dmg UNSIGNED (no Developer ID, no notarization:
|
||||
# step 5); the Windows job is unit P11c. The release holds no key today.
|
||||
# To see the artifacts without a tag: Actions > release > Run workflow,
|
||||
# pick the branch; each job uploads its files as a run artifact.
|
||||
name: release
|
||||
|
||||
on:
|
||||
@@ -75,3 +78,46 @@ jobs:
|
||||
git add -A
|
||||
git commit -m "wanderbooks $v"
|
||||
git push origin HEAD:gh-pages
|
||||
- name: Keep them with the run (a dispatch has no release)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-packages
|
||||
path: target/packages/${{ steps.version.outputs.version }}/
|
||||
|
||||
macos:
|
||||
# UNSIGNED: a .dmg that Gatekeeper refuses until step 5 signs and
|
||||
# notarizes it. Apple silicon (macos-latest); an Intel build is not
|
||||
# made yet.
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: jdx/mise-action@v2
|
||||
with:
|
||||
install_args: "rust just cargo:cargo-packager"
|
||||
- name: The tag is the program's version
|
||||
id: version
|
||||
run: |
|
||||
own=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ledger-cli/Cargo.toml | head -n1)
|
||||
tag="${GITHUB_REF_NAME#v}"
|
||||
if [ "${GITHUB_REF_TYPE}" = tag ] && [ "$tag" != "$own" ]; then
|
||||
echo "tag $GITHUB_REF_NAME is not version $own" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$own" >> "$GITHUB_OUTPUT"
|
||||
- name: Build the unsigned .dmg
|
||||
run: just package-macos ${{ steps.version.outputs.version }}
|
||||
- name: Attach it to the release
|
||||
if: github.ref_type == 'tag'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
v=${{ steps.version.outputs.version }}
|
||||
gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "wanderbooks $v"
|
||||
gh release upload "$GITHUB_REF_NAME" --clobber target/packages/$v/*.dmg
|
||||
- name: Keep it with the run (a dispatch has no release)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-dmg-unsigned
|
||||
path: target/packages/${{ steps.version.outputs.version }}/*.dmg
|
||||
@@ -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");
|
||||
}
|
||||
@@ -196,28 +196,46 @@ fn children_executable() -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// A program file's identity: device and inode, size and last write. A
|
||||
/// replaced file differs in the inode, and a rewritten one in the rest.
|
||||
type Identity = (u64, u64, u64, i64, i64);
|
||||
|
||||
fn identity_from(meta: &std::fs::Metadata) -> Identity {
|
||||
(
|
||||
meta.dev(),
|
||||
meta.ino(),
|
||||
meta.len(),
|
||||
meta.mtime(),
|
||||
meta.mtime_nsec(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The running executable's file identity, and its file's now: they
|
||||
/// differ once the file was replaced (INVARIANT 7).
|
||||
fn running_identity() -> Option<(u64, u64)> {
|
||||
/// differ once the file was replaced (INVARIANT 7). An AppImage is its
|
||||
/// image file (`APPIMAGE`), not the program inside the mount.
|
||||
fn running_identity() -> Option<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") {
|
||||
std::fs::metadata("/proc/self/exe").ok()?
|
||||
} else {
|
||||
std::fs::metadata(std::env::current_exe().ok()?).ok()?
|
||||
};
|
||||
Some((running.dev(), running.ino()))
|
||||
Some(identity_from(&running))
|
||||
}
|
||||
|
||||
/// The identity of the file at this executable's path, if it is a
|
||||
/// program (a file half-written or gone doesn't count yet).
|
||||
fn file_identity() -> Option<(u64, u64)> {
|
||||
fn file_identity() -> Option<Identity> {
|
||||
identity_of(&crate::start::this_executable().ok()?)
|
||||
}
|
||||
|
||||
/// The identity (device, inode) of the program at `path`: a file someone
|
||||
/// may run; else None.
|
||||
fn identity_of(path: &Path) -> Option<(u64, u64)> {
|
||||
/// The identity of the program at `path`: a file someone may run; else
|
||||
/// None.
|
||||
fn identity_of(path: &Path) -> Option<Identity> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
(meta.is_file() && meta.permissions().mode() & 0o111 != 0).then(|| (meta.dev(), meta.ino()))
|
||||
(meta.is_file() && meta.permissions().mode() & 0o111 != 0).then(|| identity_from(&meta))
|
||||
}
|
||||
|
||||
/// Wait at most `wait` for a host to answer on the lease socket: whether
|
||||
@@ -482,6 +500,12 @@ impl Host {
|
||||
let started_as = running_identity();
|
||||
let mut exe_poll = tokio::time::interval(self.knobs.exe_poll);
|
||||
exe_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
// The daily look for a newer release (P11a step 4): the work is
|
||||
// a thread of its own, one at a time, and only stages a file.
|
||||
let mut update_poll =
|
||||
tokio::time::interval((self.knobs.update_check / 4).max(Duration::from_millis(10)));
|
||||
update_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
let looking = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let mut idle_at = Some(Instant::now() + self.knobs.close_after);
|
||||
let server_start = tokio::time::sleep(self.knobs.server_delay);
|
||||
tokio::pin!(server_start);
|
||||
@@ -639,6 +663,20 @@ impl Host {
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = update_poll.tick(), if matches!(self.phase, Phase::Open) => {
|
||||
use std::sync::atomic::Ordering;
|
||||
if !looking.swap(true, Ordering::SeqCst) {
|
||||
let (root, every, looking) = (self.root.clone(), self.knobs.update_check, looking.clone());
|
||||
std::thread::spawn(move || {
|
||||
match crate::update::stage_if_due(&root, crate::update::program_version(), every) {
|
||||
Ok(Some(version)) => eprintln!("wanderbooks host: version {version} is downloaded and checked; it starts the next time the books open, or at `wanderbooks update`"),
|
||||
Ok(None) => {}
|
||||
Err(why) => eprintln!("wanderbooks host: the look for a newer version failed: {why}"),
|
||||
}
|
||||
looking.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
_ = exe_poll.tick(), if matches!(self.phase, Phase::Open) => {
|
||||
if started_as.is_some() && file_identity().is_some_and(|now| Some(now) != started_as) {
|
||||
eprintln!("wanderbooks host: a new version is installed: restarting");
|
||||
|
||||
@@ -33,6 +33,9 @@ pub struct Knobs {
|
||||
/// How long after books open the server first looks for a drill due
|
||||
/// (60 s), passed to the server as `--drill-first`.
|
||||
pub drill_first: Duration,
|
||||
/// How long between the host's looks for a newer release (plan
|
||||
/// P11a step 4: a day).
|
||||
pub update_check: Duration,
|
||||
}
|
||||
|
||||
impl Default for Knobs {
|
||||
@@ -47,6 +50,7 @@ impl Default for Knobs {
|
||||
open_wait: Duration::from_secs(120),
|
||||
backup_quiet: Duration::from_secs(30),
|
||||
drill_first: Duration::from_secs(60),
|
||||
update_check: Duration::from_secs(24 * 60 * 60),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +80,7 @@ impl Knobs {
|
||||
"open_wait" => k.open_wait = d,
|
||||
"backup_quiet" => k.backup_quiet = d,
|
||||
"drill_first" => k.drill_first = d,
|
||||
"update_check" => k.update_check = d,
|
||||
other => return Err(format!("{FLAG}: there is no knob {other:?}")),
|
||||
}
|
||||
}
|
||||
@@ -97,6 +102,7 @@ impl Knobs {
|
||||
("open_wait", self.open_wait, d.open_wait),
|
||||
("backup_quiet", self.backup_quiet, d.backup_quiet),
|
||||
("drill_first", self.drill_first, d.drill_first),
|
||||
("update_check", self.update_check, d.update_check),
|
||||
] {
|
||||
if now != default {
|
||||
parts.push(format!("{name}={}", now.as_millis()));
|
||||
@@ -130,6 +136,7 @@ mod tests {
|
||||
assert_eq!(k.open_wait, Duration::from_secs(120));
|
||||
assert_eq!(k.backup_quiet, Duration::from_secs(30));
|
||||
assert_eq!(k.drill_first, Duration::from_secs(60));
|
||||
assert_eq!(k.update_check, Duration::from_secs(86_400));
|
||||
assert_eq!(k.to_arg(), None);
|
||||
assert!(k.args().is_empty());
|
||||
assert_eq!(Knobs::parse("").unwrap(), k);
|
||||
@@ -138,7 +145,7 @@ mod tests {
|
||||
#[test]
|
||||
fn knobs_pass_on_exactly() {
|
||||
let k = Knobs::parse(
|
||||
"close_after=500,backoff=20,server_delay=3000,init_wait=100,queue_wait=900,exe_poll=50,open_wait=70,backup_quiet=40,drill_first=50",
|
||||
"close_after=500,backoff=20,server_delay=3000,init_wait=100,queue_wait=900,exe_poll=50,open_wait=70,backup_quiet=40,drill_first=50,update_check=60",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(k.close_after, Duration::from_millis(500));
|
||||
@@ -150,6 +157,7 @@ mod tests {
|
||||
assert_eq!(k.open_wait, Duration::from_millis(70));
|
||||
assert_eq!(k.backup_quiet, Duration::from_millis(40));
|
||||
assert_eq!(k.drill_first, Duration::from_millis(50));
|
||||
assert_eq!(k.update_check, Duration::from_millis(60));
|
||||
let args = k.args();
|
||||
assert_eq!(args[0], FLAG);
|
||||
assert_eq!(Knobs::parse(&args[1]).unwrap(), k);
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
//! it names a socket this account owns (the owner's ssh agent, so the
|
||||
//! server's backups reach a private git repository, D-30). Nothing
|
||||
//! else passes (a scope's own identifiers, such as INVOCATION_ID, are
|
||||
//! systemd's).
|
||||
//! systemd's). One more: APPIMAGE, only when it names an absolute
|
||||
//! file, so the host knows its own AppImage (below).
|
||||
//! 2. Its standard input and output are null, its errors go to
|
||||
//! run/host.log (0600); it starts a new session with umask 077. Every
|
||||
//! other descriptor is closed (std opens them close-on-exec, and the
|
||||
@@ -23,7 +24,11 @@
|
||||
//! scope couldn't be made (systemd-run failed without the host ever
|
||||
//! writing its first line), or there is no such scope, directly. A
|
||||
//! host that ran and stopped is never started a second time.
|
||||
//! 4. It runs this executable's file as it is on disk now.
|
||||
//! 4. It runs this executable's file as it is on disk now; for an
|
||||
//! AppImage that is the image file (APPIMAGE), not the program inside
|
||||
//! its mount, which goes away with the image it came from. The host
|
||||
//! reads that path about itself and never tells it to an agent (I-2),
|
||||
//! so a replaced image restarts the books like any replaced program.
|
||||
//! 5. The opener doesn't wait for it, and reaps it when it ends.
|
||||
//! ```
|
||||
|
||||
@@ -49,6 +54,9 @@ const LOG_LIMIT: u64 = 8 * 1024 * 1024;
|
||||
/// replaced file reads as "… (deleted)"; the path, not the old file, is
|
||||
/// what a new host runs.
|
||||
pub fn this_executable() -> Result<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 text = exe.to_string_lossy();
|
||||
if let Some(path) = text.strip_suffix(" (deleted)")
|
||||
@@ -59,6 +67,13 @@ pub fn this_executable() -> Result<PathBuf, String> {
|
||||
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.
|
||||
fn own_uid() -> u32 {
|
||||
// SAFETY: geteuid has no preconditions and can't fail.
|
||||
@@ -128,7 +143,11 @@ fn own_agent(named: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
|
||||
|
||||
/// The host's environment (INVARIANT 1), given the opener's
|
||||
/// `SSH_AUTH_SOCK`.
|
||||
fn host_env(home: &Path, ssh_agent: Option<&std::ffi::OsStr>) -> Vec<(String, std::ffi::OsString)> {
|
||||
fn host_env(
|
||||
home: &Path,
|
||||
ssh_agent: Option<&std::ffi::OsStr>,
|
||||
appimage: Option<&std::ffi::OsStr>,
|
||||
) -> Vec<(String, std::ffi::OsString)> {
|
||||
let mut env = vec![
|
||||
("PATH".to_string(), PATH.into()),
|
||||
("HOME".to_string(), home.as_os_str().to_os_string()),
|
||||
@@ -142,6 +161,9 @@ fn host_env(home: &Path, ssh_agent: Option<&std::ffi::OsStr>) -> Vec<(String, st
|
||||
if let Some(agent) = own_agent(ssh_agent) {
|
||||
env.push(("SSH_AUTH_SOCK".into(), agent));
|
||||
}
|
||||
if let Some(image) = own_appimage(appimage) {
|
||||
env.push(("APPIMAGE".into(), image));
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
@@ -183,6 +205,10 @@ pub fn start_host(
|
||||
let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?;
|
||||
let run = found.run_dir().map_err(|e| e.to_string())?;
|
||||
let home = ledger_store::root::account_home().map_err(|e| e.to_string())?;
|
||||
// A staged update is applied here and nowhere while books are open:
|
||||
// no host runs now, so no call is cut off (update.rs INVARIANT 9). A
|
||||
// failure leaves the program as it was.
|
||||
let _ = crate::update::apply_staged(&found, crate::update::program_version());
|
||||
let exe = this_executable()?;
|
||||
let mut args: Vec<std::ffi::OsString> = vec!["host".into()];
|
||||
// An explicit root goes on as found (absolute): the host starts in /.
|
||||
@@ -227,6 +253,7 @@ pub fn start_host(
|
||||
.envs(host_env(
|
||||
&home,
|
||||
std::env::var_os("SSH_AUTH_SOCK").as_deref(),
|
||||
std::env::var_os("APPIMAGE").as_deref(),
|
||||
))
|
||||
.current_dir("/")
|
||||
.stdin(Stdio::null())
|
||||
@@ -385,7 +412,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_host_environment_carries_nothing_else() {
|
||||
let env = host_env(Path::new("/home/someone"), None);
|
||||
let env = host_env(Path::new("/home/someone"), None, None);
|
||||
let names: Vec<&str> = env.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(&names[..2], ["PATH", "HOME"]);
|
||||
for n in &names[2..] {
|
||||
@@ -393,7 +420,8 @@ mod tests {
|
||||
[
|
||||
"XDG_RUNTIME_DIR",
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"SSH_AUTH_SOCK"
|
||||
"SSH_AUTH_SOCK",
|
||||
"APPIMAGE"
|
||||
]
|
||||
.contains(n),
|
||||
"{n}"
|
||||
@@ -403,6 +431,22 @@ mod tests {
|
||||
assert_eq!(env[1].1, "/home/someone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_absolute_file_passes_as_the_appimage() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let image = dir.path().join("W.AppImage");
|
||||
std::fs::write(&image, b"x").unwrap();
|
||||
let home = Path::new("/home/someone");
|
||||
let has = |v: Option<&std::ffi::OsStr>| {
|
||||
host_env(home, None, v).iter().any(|(n, _)| n == "APPIMAGE")
|
||||
};
|
||||
assert!(has(Some(image.as_os_str())));
|
||||
assert!(!has(None));
|
||||
assert!(!has(Some("W.AppImage".as_ref())), "relative");
|
||||
assert!(!has(Some(dir.path().as_os_str())), "a folder");
|
||||
assert!(!has(Some(dir.path().join("gone").as_os_str())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_own_ssh_agent_socket_passes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -413,7 +457,7 @@ mod tests {
|
||||
Some(sock.clone().into_os_string())
|
||||
);
|
||||
// It reaches the host's environment, once.
|
||||
let env = host_env(Path::new("/home/someone"), Some(sock.as_os_str()));
|
||||
let env = host_env(Path::new("/home/someone"), Some(sock.as_os_str()), None);
|
||||
let agents: Vec<_> = env.iter().filter(|(n, _)| n == "SSH_AUTH_SOCK").collect();
|
||||
assert_eq!(agents.len(), 1);
|
||||
assert_eq!(agents[0].1, sock.as_os_str());
|
||||
@@ -422,7 +466,7 @@ mod tests {
|
||||
let link = dir.path().join("link.sock");
|
||||
std::os::unix::fs::symlink(&sock, &link).unwrap();
|
||||
assert_eq!(own_agent(Some(link.as_os_str())), None);
|
||||
let env = host_env(Path::new("/home/someone"), Some(link.as_os_str()));
|
||||
let env = host_env(Path::new("/home/someone"), Some(link.as_os_str()), None);
|
||||
assert!(env.iter().all(|(n, _)| n != "SSH_AUTH_SOCK"));
|
||||
// Named relative to here: not taken, even where it leads to one.
|
||||
let here = std::env::current_dir().unwrap();
|
||||
|
||||
@@ -36,7 +36,17 @@
|
||||
//! newer one: the only way back is a restore, plan 3.9).
|
||||
//! 7. A package-managed install is never touched.
|
||||
//! 8. A program with no release key compiled in updates from nothing
|
||||
//! but a local (`file://`) feed whose key the owner names.
|
||||
//! but a local (`file://`) feed whose key the owner names: on the
|
||||
//! command line, or in `<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:
|
||||
//! 1. IF the feed offers a higher version, signed by our key: SHOULD
|
||||
//! replace the program and keep the old one.
|
||||
@@ -44,6 +54,10 @@
|
||||
//! is by another key or missing: SHOULD refuse, changing nothing.
|
||||
//! 3. IF the new program can't start: SHOULD put the old one back.
|
||||
//! 4. IF a book consented to a newer format: SHOULD refuse to roll back.
|
||||
//! 5. IF the host's daily look finds a signed newer release: SHOULD
|
||||
//! stage it and change nothing running; the next host start runs it.
|
||||
//! 6. IF the daily look finds a bad signature or checksum: SHOULD stage
|
||||
//! nothing.
|
||||
//! ```
|
||||
|
||||
use serde::Deserialize;
|
||||
@@ -72,6 +86,26 @@ const UPDATES: &str = "updates";
|
||||
const PREVIOUS: &str = "previous";
|
||||
const PROGRAM: &str = "wanderbooks";
|
||||
const META: &str = "meta.json";
|
||||
const STAGED: &str = "staged";
|
||||
const FEED_FILE: &str = "feed";
|
||||
const CHECKED: &str = "checked";
|
||||
|
||||
/// The version of the program being run, set by its `main` (this crate's
|
||||
/// own is the fallback: the packages rewrite only the program's).
|
||||
static PROGRAM_VERSION: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
|
||||
|
||||
/// Say which version this program is.
|
||||
pub fn set_program_version(version: &'static str) {
|
||||
let _ = PROGRAM_VERSION.set(version);
|
||||
}
|
||||
|
||||
/// This program's version.
|
||||
pub fn program_version() -> &'static str {
|
||||
PROGRAM_VERSION
|
||||
.get()
|
||||
.copied()
|
||||
.unwrap_or(env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
const MAX_MANIFEST: usize = 1 << 20;
|
||||
const MAX_PROGRAM: u64 = 1 << 30;
|
||||
@@ -244,11 +278,18 @@ pub fn update(
|
||||
Install::SelfUpdating(target) => target,
|
||||
};
|
||||
let current_v = parse_version(current).ok_or("this program's version isn't one of ours")?;
|
||||
let feed = flags.feed.as_deref().unwrap_or(DEFAULT_FEED);
|
||||
let key = trusted_key(feed, flags.key_file.as_deref())?;
|
||||
let manifest_bytes = fetch(feed, MAX_MANIFEST as u64)?;
|
||||
let manifest: Manifest = serde_json::from_slice(&manifest_bytes)
|
||||
.map_err(|e| format!("the release manifest isn't one we can read: {e}"))?;
|
||||
let found_root = ledger_store::root::Root::find(flags.root.as_deref()).ok();
|
||||
let local = found_root
|
||||
.as_ref()
|
||||
.and_then(|r| local_feed(&r.path().join(UPDATES)));
|
||||
let (feed, key_file) = match (&flags.feed, &local) {
|
||||
(Some(feed), _) => (feed.clone(), flags.key_file.clone()),
|
||||
(None, Some((feed, key))) => (feed.clone(), Some(key.clone())),
|
||||
(None, None) => (DEFAULT_FEED.to_string(), flags.key_file.clone()),
|
||||
};
|
||||
let feed = feed.as_str();
|
||||
let key = trusted_key(feed, key_file.as_deref())?;
|
||||
let manifest = fetch_manifest(feed)?;
|
||||
let offered = parse_version(&manifest.version).ok_or_else(|| {
|
||||
format!(
|
||||
"the manifest's version {:?} isn't one of ours",
|
||||
@@ -272,9 +313,14 @@ pub fn update(
|
||||
.get(&platform)
|
||||
.ok_or_else(|| format!("release {} has no build for {platform}", manifest.version))?;
|
||||
if flags.check {
|
||||
let ready = found_root
|
||||
.as_ref()
|
||||
.and_then(|r| staged_bytes(&r.path().join(UPDATES), &manifest.version))
|
||||
.is_some();
|
||||
println!(
|
||||
"wanderbooks {} is available (this is {current}). Run `{}` to install it.",
|
||||
"wanderbooks {} is available{} (this is {current}). Run `{}` to install it.",
|
||||
manifest.version,
|
||||
if ready { ", already downloaded" } else { "" },
|
||||
prog.trim_end_matches(" --check")
|
||||
);
|
||||
if !manifest.notes.trim().is_empty() {
|
||||
@@ -284,9 +330,14 @@ pub fn update(
|
||||
}
|
||||
let root = ledger_store::root::Root::find(flags.root.as_deref()).map_err(|e| e.to_string())?;
|
||||
let updates = updates_dir(&root)?;
|
||||
let _lock = take_lock(&updates)?;
|
||||
let _lock = take_lock_within(&updates, Duration::from_secs(10))?;
|
||||
|
||||
let bytes = fetch(&resolve(feed, &entry.url)?, MAX_PROGRAM)?;
|
||||
// A copy the host already downloaded and verified is used as it is
|
||||
// found (and verified again below); else the download.
|
||||
let bytes = match staged_bytes(&updates, &manifest.version) {
|
||||
Some(bytes) => bytes,
|
||||
None => fetch(&resolve(feed, &entry.url)?, MAX_PROGRAM)?,
|
||||
};
|
||||
verify(&bytes, entry, &key)?;
|
||||
|
||||
// Verified: from here the files are ours to write. Staged, then the
|
||||
@@ -313,6 +364,7 @@ pub fn update(
|
||||
.into());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&staged_dir);
|
||||
let _ = std::fs::remove_dir_all(updates.join(STAGED));
|
||||
println!(
|
||||
"Updated wanderbooks from {current} to {}.",
|
||||
manifest.version
|
||||
@@ -323,6 +375,182 @@ pub fn update(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The manifest at `feed`.
|
||||
fn fetch_manifest(feed: &str) -> Result<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
|
||||
/// replaced.
|
||||
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 updates = updates_dir(&root)?;
|
||||
let _lock = take_lock(&updates)?;
|
||||
let _lock = take_lock_within(&updates, Duration::from_secs(10))?;
|
||||
let previous = updates.join(PREVIOUS);
|
||||
let meta: serde_json::Value = std::fs::read(previous.join(META))
|
||||
.ok()
|
||||
@@ -562,6 +790,24 @@ fn private_dir(dir: &Path) -> Result<(), Failure> {
|
||||
|
||||
/// One update or rollback at a time.
|
||||
fn take_lock(updates: &Path) -> Result<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()
|
||||
.create(true)
|
||||
.append(true)
|
||||
|
||||
@@ -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>
|
||||
Executable
+33
@@ -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"
|
||||
@@ -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
@@ -355,9 +355,21 @@ downgrading is theirs). Anything else (an AppImage, whose file is named by
|
||||
without the agent noticing. The format gate stays the server's (3.9):
|
||||
`rollback` refuses while a book declares a format or feature the kept
|
||||
version does not know;
|
||||
- an AppImage is the exception to the restart: the host runs from the old
|
||||
image's mount, whose path does not change, so it keeps running the old
|
||||
version until the books are next closed and opened (docs/packaging.md).
|
||||
- an AppImage runs the program from a mount that goes away with the image,
|
||||
so the host learns the image's own path from `APPIMAGE` (the one variable
|
||||
`start.rs` adds to its scrubbed environment, only when it names an
|
||||
absolute file; the host reads it about itself and never tells an agent),
|
||||
runs and restarts from that file, and tells a replaced program by its
|
||||
inode, size and last write, like any other;
|
||||
- updates are also **staged** (step 4): while the books are open the host
|
||||
looks at the feed at most once a day (`update_check` knob; the look is
|
||||
noted in `updates/checked`), and only downloads and verifies a newer
|
||||
release into `<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`
|
||||
(cargo-packager: `.deb`, AppImage, the pacman tarball with its PKGBUILD),
|
||||
@@ -365,7 +377,9 @@ the rpm table in `crates/ledger-cli/Cargo.toml`, `aur/PKGBUILD` (the AUR
|
||||
package, which repacks the release's tarball), `build-linux.sh`,
|
||||
`repo-indexes.sh` (the apt and dnf indexes), `local-release.sh` (a
|
||||
throwaway-keyed feed on this machine) and `test-linux-packages.sh`.
|
||||
`.github/workflows/release.yml` builds them on a version tag.
|
||||
`.github/workflows/release.yml` builds them on a version tag. The macOS
|
||||
bundle is `packager-macos.toml`, `Info.plist` and `build-macos.sh` (an
|
||||
unsigned `.dmg` from CI's `macos` job; signing and notarization are step 5).
|
||||
|
||||
## The result envelope and the guards (`ledger-mcp`, P5)
|
||||
|
||||
|
||||
+50
-16
@@ -16,8 +16,8 @@ not a tool, so there is nothing for a recipe to call.
|
||||
| `.rpm` | `cargo-generate-rpm` (table in `crates/ledger-cli/Cargo.toml`) | same paths, marker `rpm`; 36 MB | dnf repository (`repo-indexes.sh dnf`) | unsigned until step 5 (`--signing-key` takes the PGP key then) |
|
||||
| AUR `PKGBUILD` | `cargo packager` makes the tarball (`pacman` format) and a PKGBUILD for it; ours is `deploy/packaging/aur/PKGBUILD`, which repacks the release's tarball (package `wanderbooks-bin`) | same paths, marker `aur`; built with `makepkg` | pacman (the AUR keeps old PKGBUILDs) | n/a (pacman trusts the AUR's checksums) |
|
||||
| AppImage | `cargo packager` (`appimage`); 44 MB | one file the owner puts anywhere and runs; needs no admin password | `wanderbooks update` (`$APPIMAGE` is the file replaced) against the manifest; minisign | minisign by the release key (step 5) |
|
||||
| `.dmg` | `cargo packager` (`dmg`) | (trial) needs a Mac | `wanderbooks update` | Developer ID and notarization (step 5) |
|
||||
| NSIS `.exe` | `cargo packager` (`nsis`) | (trial) needs Windows, and native Windows is unit P11c (D-62) | `wanderbooks update` | the Windows identity (step 5) |
|
||||
| `.dmg` | `cargo packager` (`dmg`), `deploy/packaging/build-macos.sh`; **built in CI, unsigned; the trial on a Mac is the owner's** | `Wanderbooks.app` dragged to Applications; no admin password (the trial will say) | `wanderbooks update` (it replaces the program inside the `.app`: see the note below) | **unsigned**; Developer ID and notarization are step 5 |
|
||||
| NSIS `.exe` | `cargo packager` (`nsis`) | **not built**: native Windows is unit P11c (D-62) | `wanderbooks update` | the Windows identity (step 5) |
|
||||
|
||||
`just package-linux` builds the first four (`deploy/packaging/build-linux.sh`,
|
||||
about a minute after the release build, itself 8 to 12 minutes on a
|
||||
@@ -64,21 +64,18 @@ What the trial found, each of which is built around now:
|
||||
manifest's `signature` is that `.sig` as written, and `update` reads the
|
||||
public key in either form. Checked end to end by hand (below), and by
|
||||
`a_public_key_is_read_in_every_form_minisign_and_the_packager_write_it`.
|
||||
7. **The AppImage updates itself but the books it holds do not restart
|
||||
onto the new image.** `update` replaces `$APPIMAGE`, and an AppImage run
|
||||
from a shell (`Wanderbooks.AppImage update`, or the one that was running)
|
||||
proves the swap and the rollback: after `update`, the AppImage on disk
|
||||
is the new file, the old one is kept, and `rollback` restores it. But the
|
||||
host that is open runs from the old image's mount
|
||||
7. **The AppImage updates itself, and (since step 4) the books it holds
|
||||
restart onto the new image.** `update` replaces `$APPIMAGE`. The host
|
||||
that is open used to run from the old image's mount
|
||||
(`/tmp/.mount_*/usr/bin/wanderbooks`), a path that does not change, so
|
||||
`host.rs` never sees a replaced file and the open books keep the old
|
||||
version until they are closed and opened again (`wanderbooks close`).
|
||||
Fixing it means the host learning the image's path (its environment is
|
||||
scrubbed on purpose; `APPIMAGE` would join the allow-list of `start.rs`,
|
||||
or the path would come from the mount table) and starting its successor
|
||||
from it: a decision about what the host may trust, left to the EM (the
|
||||
unit's report names it). Package installs and a hand-placed executable
|
||||
restart as designed (`bridge_survives_update`).
|
||||
it never saw a replaced file. The host now reads `APPIMAGE` (it is in
|
||||
the allow-list of `start.rs`'s scrubbed environment, only when it names
|
||||
an absolute file; a path the host reads about itself and never tells an
|
||||
agent), runs and restarts from that file, and tells a replaced program
|
||||
by its inode, size and last write. Tested without FUSE by
|
||||
`a_replaced_appimage_restarts_the_books` (the programs run from one
|
||||
copy while `APPIMAGE` names another, as a mount does); not run against
|
||||
a real image mount in this wave.
|
||||
8. **Onboarding with no terminal does not exist yet.** The unit's Done-when
|
||||
(1) says an AppImage "opens, onboards and posts an entry with no
|
||||
terminal". The AppImage opens (a bare launch is the viewer), but the viewer
|
||||
@@ -118,6 +115,43 @@ package manager. Not tested: Ubuntu, openSUSE, a package-manager
|
||||
*downgrade* (the repositories keep every release by construction), and the
|
||||
`.rpm` and `.deb` under their real signatures (step 5).
|
||||
|
||||
## The macOS bundle (step 6, built in CI, unsigned)
|
||||
|
||||
`deploy/packaging/packager-macos.toml` (cargo-packager: the `dmg` format,
|
||||
which makes the `.app` first; the icon is the 512 px PNG, made into an
|
||||
`.icns` by the packager; `minimum-system-version` 11.0), `Info.plist`
|
||||
(merged into the generated one: one viewer at a time, no discrete GPU),
|
||||
`build-macos.sh` (the same shape as the Linux script, in portable shell
|
||||
because macOS has BSD `sed`) and `just package-macos`. The `macos` job of
|
||||
`.github/workflows/release.yml` runs on `macos-latest` (Apple silicon: the
|
||||
manifest key is `macos-aarch64`; an Intel build is not made), uploads the
|
||||
`.dmg` as a run artifact (named `macos-dmg-unsigned`) and, on a tag,
|
||||
attaches it to the release. **It is not signed or notarized** (step 5: the
|
||||
owner's Developer ID and Q-4), so Gatekeeper refuses it on a Mac that
|
||||
downloaded it; the owner opens it with right-click, Open. What was checked
|
||||
here, on Linux: the config parses under cargo-packager 0.11.8's strict
|
||||
schema (unknown keys are errors) and the script's `sed` is the portable
|
||||
subset. **Not checked: that the `.dmg` builds, mounts or runs**: that needs
|
||||
the CI run (Actions, release, Run workflow, this branch) and then a Mac.
|
||||
Open items for the trial on a Mac: whether `wanderbooks update` replacing
|
||||
the program inside a signed `.app` breaks its signature (it will: step 5
|
||||
must decide, probably by updating a whole `.app`); the translocated-app
|
||||
check (`translocated_app_refuses_to_connect`) is not built.
|
||||
|
||||
## Updates staged by the host (step 4)
|
||||
|
||||
The host looks at the feed at most once a day while the books are open
|
||||
(`update_check`), and downloads and verifies a newer release into
|
||||
`<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
|
||||
|
||||
`manifest.json` at `releases/latest/download/` (D-26):
|
||||
|
||||
@@ -3711,6 +3711,25 @@ it (the same program with a tail, so its hash differs). All in
|
||||
file now on disk (not a deleted one). The host's own restart laws stay
|
||||
`open_while_held.rs`'s (`a_restart_is_not_a_close`).
|
||||
|
||||
Step 3's gap and step 4 (same file):
|
||||
|
||||
- `a_replaced_appimage_restarts_the_books`: the programs run from one copy
|
||||
(a mount's) while `APPIMAGE` names another (the image); `update`
|
||||
replaces only the image; the host restarts onto it (its `/proc/PID/exe`
|
||||
is the image), the mount's copy is untouched, the session writes on.
|
||||
- `the_host_stages_a_daily_update_and_the_next_start_applies_it`: with a
|
||||
local feed named in `updates/feed`, the host stages 9.0.0 by itself;
|
||||
the program, the host and the session are unchanged meanwhile; after
|
||||
`close`, the next opener applies it (the old program is kept, the staged
|
||||
folder gone) and the books are as they were.
|
||||
- `a_daily_update_that_is_not_ours_is_not_staged`: a signature by another
|
||||
key, then a wrong sha256, each looked at and neither staged.
|
||||
- `update_installs_the_copy_the_host_staged_without_downloading_again`: the
|
||||
release's file is deleted; `update` installs from the staged copy.
|
||||
- `a_staged_update_that_was_tampered_with_is_dropped_not_applied`: a staged
|
||||
file edited together with its recorded checksum fails the signature; the
|
||||
next start drops it and runs the old program.
|
||||
|
||||
Unit tests in `update.rs`: `versions_are_three_numbers_and_order_by_number`,
|
||||
`a_package_marker_beside_the_program_makes_it_package_managed`,
|
||||
`a_manifest_address_is_resolved_beside_the_manifest`,
|
||||
|
||||
@@ -273,6 +273,11 @@ package-linux version="" formats="deb,appimage,pacman":
|
||||
cargo build --release -p ledger-cli
|
||||
deploy/packaging/build-linux.sh {{version}} {{formats}}
|
||||
|
||||
# Build the unsigned macOS .dmg (on a Mac; CI does it on a tag or dispatch)
|
||||
package-macos version="":
|
||||
cargo build --release -p ledger-cli
|
||||
deploy/packaging/build-macos.sh {{version}}
|
||||
|
||||
# A release folder on this machine signed with a throwaway key; then
|
||||
# `wanderbooks update --feed file://DIR/manifest.json --trusted-key DIR/release.pub`
|
||||
# Make a local release feed to try `wanderbooks update` against
|
||||
|
||||
+1
-1
@@ -1658,7 +1658,7 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1
|
||||
5. **Files:** owns `crates/ledger-host/src/update.rs`, `deploy/packaging/*`, `.github/workflows/release.yml`, `docs/packaging.md`; touches `commands.rs`, `crates/ledger-gui/src/owner.rs`, `mise.toml`.
|
||||
6. **Tests and proofs:** named above.
|
||||
7. **Done when:** (1) on a clean Linux VM the AppImage installs, opens, onboards and posts an entry with no terminal and no admin password, and updates itself from a test feed; (2) on Arch, Debian and Fedora the packages install and upgrade with the head unchanged; (3) an update leaves every book's head and goldens unchanged and a bridge session carries on across it; (4) a backup made on one system restores on another; (5) macOS and Windows: the same as (1), once the signing prerequisites exist (record which are done in the As-built block); (6) `just check` green.
|
||||
8. **As built:** (steps 0 to 3 as far as stated, 2026-10-29; steps 4 to 7 are later waves.) **Step 0, partly.** Built: the macOS login Keychain store (`apple-native-keyring-store` 1.0.2, feature `keychain`) and the Windows Credential Manager store (`windows-native-keyring-store` 1.1.0) behind `tokens.rs::Keeper`, on the same `Items` trait and the same laws as the Secret Service (`os` modules per OS; shared `refused`, `make_in`, `read_item`, `remove_item`); `NOT_YET` now only for other OSes; the ignored `os_keyring_keeps_and_forgets` compiles on all three; the `portable` job in `.github/workflows/check.yml` (`macos-latest` gating, `windows-latest` allowed to fail) builds `ledger-cli --no-default-features`, `cargo build --workspace --all-targets`, `cargo test --workspace` and the ignored keyring test. Checked here: Linux only (fmt, clippy, the tokens tests); no macOS/Windows target is installed, so both stores and the job are proven only by the first CI run. **Not built, and not a `cfg` gap: the Windows host.** Windows data root from the known-folder API with an owner-only DACL, named pipes with a SID check, `flock` and the Unix-only `host`, `start`, `bridge`, `opener` and `local`/`admin` sockets are a port of several crates (inventory in `docs/packaging.md`); macOS needs the CI run to show which Linux-only items (`seccomp` in `refuse.rs`, `renameat2`, `close_range`) need fences. So the step 0 Done-when ("setup ... gui work on each OS") is open for both. **Step 1 (the trial): not run in that wave**: it needs clean macOS, Windows and Linux machines; `mise.toml` now pins `cargo-packager` 0.11.8, `cargo-packager-updater` 0.2.3, `cargo-generate-rpm` 0.21.1 and `docs/packaging.md` holds the empty table and what is known. **Steps 1 (Linux half), 2 and 3 (2026-10-29, branch `P11a`).** *Pins:* the EM removed step 0's early pins; `mise.toml` now pins `cargo-packager` 0.11.8 and `cargo-generate-rpm` 0.21.1, which `just package-linux` calls; **`cargo-packager-updater` is a library, not a tool, so it is not pinned** (the plan's three-tool list was wrong on that); `update.rs` verifies with `minisign-verify` 0.3 (the crate the updater uses) and the tests sign with the `minisign` 0.10 dev-dependency. *Step 1:* `docs/packaging.md` has the Linux rows filled from real builds (47 MB .deb, 36 MB .rpm, 44 MB AppImage, the pacman tarball and PKGBUILD) and eight findings; the `.dmg` and NSIS rows stay "(trial)" (need a Mac and Windows; Windows is P11c). The findings that shaped the build: a binary linked on this machine (glibc 2.44) does not start on Debian 13, so packages are built on `ubuntu-24.04` in CI and in `rust:1.99-trixie` locally (`just release-trixie`); the books refuse to run under Docker's default seccomp filter (tests use `seccomp=unconfined`); an AppImage's launcher appends arguments to the desktop file's `Exec`, so the desktop files say plain `wanderbooks` and a bare launch with no terminal is the viewer (`main.rs::is_bare_launch`); the AppImage needs a square icon (**a placeholder PNG is committed; the owner should replace it**). *Step 2:* `crates/ledger-host/src/update.rs` (`update`, `rollback`) and the two CLI modes: a manifest (version; per platform `url`, `sha256`, `signature` = base64 of the minisign `.sig`, the shape cargo-packager signs in), accepted only if higher, sha256 equal AND signature valid against `WANDERBOOKS_RELEASE_PUBKEY` (compiled in; **no build carries one until step 5**, so today a build updates only from a `file://` feed with `--trusted-key`); stage under `<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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user