merge: P11a steps 1 (Linux), 2 and 3: the trial's Linux column, update and rollback, the Linux packages (branch P11a, d6c90a11)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
commit
54312b3980
29 files changed
+2370
-29
No files matched your search
@@ -0,0 +1,77 @@
|
||||
# Releases (plan P11a, D-24, D-26): on a version tag, build the Linux
|
||||
# packages on the oldest system they claim (ubuntu-24.04, glibc 2.39; a
|
||||
# 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.
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-24.04
|
||||
# Same disk discipline as check.yml: no incremental caches.
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Room for the build (the image ships toolchains we never use)
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
|
||||
/opt/hostedtoolcache/CodeQL /usr/local/.ghcup
|
||||
df -h / | tail -1
|
||||
- uses: jdx/mise-action@v2
|
||||
with:
|
||||
# Only what packaging needs, not the proof and fuzz tools.
|
||||
install_args: "rust just cargo:cargo-packager cargo:cargo-generate-rpm"
|
||||
- name: GUI build dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libxkbcommon-dev libgtk-3-dev apt-utils createrepo-c
|
||||
- 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 packages
|
||||
run: just package-linux ${{ steps.version.outputs.version }}
|
||||
- name: Attach them 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/*.AppImage target/packages/$v/*.deb target/packages/$v/*.rpm target/packages/$v/*.tar.gz
|
||||
- name: Add them to the apt and dnf repositories (gh-pages)
|
||||
if: github.ref_type == 'tag'
|
||||
run: |
|
||||
v=${{ steps.version.outputs.version }}
|
||||
git fetch origin gh-pages || true
|
||||
if git rev-parse --verify origin/gh-pages >/dev/null 2>&1; then
|
||||
git worktree add repo origin/gh-pages
|
||||
else
|
||||
git worktree add --detach repo && git -C repo checkout --orphan gh-pages && git -C repo rm -rfq . || true
|
||||
fi
|
||||
mkdir -p repo/apt/pool repo/dnf
|
||||
cp target/packages/$v/*.deb repo/apt/pool/
|
||||
cp target/packages/$v/*.rpm repo/dnf/
|
||||
deploy/packaging/repo-indexes.sh apt repo/apt
|
||||
deploy/packaging/repo-indexes.sh dnf repo/dnf
|
||||
cd repo
|
||||
git config user.name "wanderbooks release"
|
||||
git config user.email "release@wanderbooks.ai"
|
||||
git add -A
|
||||
git commit -m "wanderbooks $v"
|
||||
git push origin HEAD:gh-pages
|
||||
Generated
+93
-8
@@ -130,7 +130,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.2",
|
||||
"inout",
|
||||
"inout 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -139,7 +139,7 @@ version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"cipher 0.5.2",
|
||||
"cpubits",
|
||||
"cpufeatures 0.3.1",
|
||||
]
|
||||
@@ -985,7 +985,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cipher 0.5.2",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
@@ -998,7 +998,7 @@ checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"cipher",
|
||||
"cipher 0.5.2",
|
||||
"poly1305",
|
||||
]
|
||||
|
||||
@@ -1061,6 +1061,16 @@ dependencies = [
|
||||
"half",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"inout 0.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
@@ -1069,7 +1079,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"crypto-common 0.2.2",
|
||||
"inout",
|
||||
"inout 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1450,6 +1460,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ct-codecs"
|
||||
version = "1.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd9f3db6f2cfef61c10613071955154ffdc9e515daebff26de4b54e35038fdd"
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.3"
|
||||
@@ -1592,6 +1608,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3011,6 +3028,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
@@ -3464,6 +3490,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.2"
|
||||
@@ -3848,6 +3883,7 @@ dependencies = [
|
||||
"ledger-store",
|
||||
"ledger-vault",
|
||||
"libc",
|
||||
"minisign",
|
||||
"proptest",
|
||||
"reqwest",
|
||||
"rmcp",
|
||||
@@ -3932,6 +3968,7 @@ dependencies = [
|
||||
"ledger-mcp",
|
||||
"ledger-store",
|
||||
"libc",
|
||||
"minisign-verify",
|
||||
"reqwest",
|
||||
"rpassword",
|
||||
"serde",
|
||||
@@ -4260,6 +4297,24 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3062dbbf4d8f57899e2252804f7512a4fd89c8211c58fa6c786d38794a0c809d"
|
||||
dependencies = [
|
||||
"ct-codecs",
|
||||
"getrandom 0.4.3",
|
||||
"rpassword",
|
||||
"scrypt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -5016,6 +5071,16 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"hmac 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.13.0"
|
||||
@@ -5023,7 +5088,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
"hmac",
|
||||
"hmac 0.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6255,6 +6320,15 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "salsa20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
|
||||
dependencies = [
|
||||
"cipher 0.4.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -6311,6 +6385,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scrypt"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
|
||||
dependencies = [
|
||||
"pbkdf2 0.12.2",
|
||||
"salsa20",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -9314,12 +9399,12 @@ dependencies = [
|
||||
"deflate64",
|
||||
"flate2",
|
||||
"getrandom 0.4.3",
|
||||
"hmac",
|
||||
"hmac 0.13.0",
|
||||
"indexmap",
|
||||
"lzma-rust2",
|
||||
"memchr",
|
||||
"nix",
|
||||
"pbkdf2",
|
||||
"pbkdf2 0.13.0",
|
||||
"ppmd-rust",
|
||||
"sha1",
|
||||
"time",
|
||||
|
||||
+16
@@ -19,3 +19,19 @@ opt-level = 3
|
||||
|
||||
[profile.dev.package.blake2]
|
||||
opt-level = 3
|
||||
|
||||
# `wanderbooks update` hashes and verifies a whole program (P11a); its
|
||||
# tests do it over a debug build's hundreds of megabytes.
|
||||
[profile.dev.package.sha2]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.minisign]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.minisign-verify]
|
||||
opt-level = 3
|
||||
|
||||
# The program that ships in the packages (P11a): symbols stripped, so the
|
||||
# AppImage, .deb and .rpm are not carrying debug tables.
|
||||
[profile.release]
|
||||
strip = "symbols"
|
||||
@@ -95,6 +95,15 @@ create acme`, `wanderbooks connect claude --book acme` (or `connect codex`).
|
||||
Then talk to the agent; `wanderbooks gui acme` shows the books, and its
|
||||
Books menu backs them up, restores, starts new books and closes them.
|
||||
|
||||
Updating: an AppImage, or a `wanderbooks` you put somewhere yourself, brings
|
||||
itself to the newest release with `wanderbooks update` (it checks the
|
||||
download against our release key, keeps the version it replaces, and the
|
||||
open books restart onto the new one when idle); `wanderbooks rollback` goes
|
||||
back, unless a book has since moved to a newer journal format. A `.deb`,
|
||||
`.rpm` or AUR install is updated by its package manager and `update` says
|
||||
so. The Linux packages are built here with `just package-linux`
|
||||
(docs/packaging.md); none is published yet.
|
||||
|
||||
## Developing
|
||||
|
||||
Every tool the recipes use (Rust, just, Kani, Lean, cargo-mutants,
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
@@ -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
|
||||
@@ -329,6 +331,8 @@ 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),
|
||||
"rollback" => ledger_host::update::rollback(&prog, args),
|
||||
_ => ledger_host::commands::books(&prog, args),
|
||||
};
|
||||
match result {
|
||||
@@ -385,6 +389,15 @@ fn run_server(_args: Vec<String>) -> i32 {
|
||||
2
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -401,6 +414,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() {
|
||||
@@ -409,7 +426,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()))
|
||||
@@ -435,7 +452,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);
|
||||
@@ -474,3 +492,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));
|
||||
}
|
||||
}
|
||||
@@ -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(¬hing).contains("no earlier version"),
|
||||
"{}",
|
||||
text(¬hing)
|
||||
);
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -24,6 +24,8 @@ pub mod opener;
|
||||
#[cfg(unix)]
|
||||
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.
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -32,8 +32,10 @@ sudo install -D -m 0644 deploy/wanderbooks-server.service \
|
||||
```
|
||||
|
||||
The executable is the server-only build: no viewer, no window libraries.
|
||||
On a Linux package install (P11) this is the same binary and the package
|
||||
manager writes the file; until then, copy it as above.
|
||||
The Linux packages (`.deb`, `.rpm`, AUR; `just package-linux`, docs/packaging.md)
|
||||
carry the viewer build, which also serves: on a package install the package
|
||||
manager writes the file and `wanderbooks update` refuses. A server with no
|
||||
window libraries is still this server-only build, copied as above.
|
||||
|
||||
## 2. Make the data root, once
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Maintainer: Stephen Waits
|
||||
# The AUR package (P11a step 3, D-24, D-26): it repacks the tarball of the
|
||||
# release's files that GitHub Releases serves; nothing is built from source.
|
||||
# `updpkgsums` fills the checksum when a release is published.
|
||||
pkgname=wanderbooks-bin
|
||||
pkgver=0.1.0
|
||||
pkgrel=1
|
||||
pkgdesc="Double-entry bookkeeping your agent keeps and you can read"
|
||||
arch=('x86_64')
|
||||
url="https://wanderbooks.ai"
|
||||
license=('MIT' 'Apache-2.0')
|
||||
depends=('gcc-libs' 'glibc')
|
||||
provides=('wanderbooks')
|
||||
conflicts=('wanderbooks')
|
||||
options=('!lto' '!strip')
|
||||
# The tarball holds usr/bin/wanderbooks, usr/share/applications/ and
|
||||
# usr/share/wanderbooks/install-method (which makes `wanderbooks update`
|
||||
# refuse and send the owner to pacman).
|
||||
source=("wanderbooks_${pkgver}_x86_64.tar.gz::https://github.com/swaits/wanderbooks/releases/download/v${pkgver}/wanderbooks_${pkgver}_x86_64.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
noextract=()
|
||||
|
||||
package() {
|
||||
cp -r "${srcdir}/usr" "${pkgdir}/usr"
|
||||
}
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# The Linux packages (P11a step 3), from the release program already built
|
||||
# at target/release/wanderbooks (`cargo build --release -p ledger-cli`).
|
||||
#
|
||||
# build-linux.sh [VERSION] [FORMATS]
|
||||
#
|
||||
# Puts into target/packages/VERSION/: the .deb, the AppImage and the
|
||||
# pacman tarball with its PKGBUILD (cargo-packager), and the .rpm
|
||||
# (cargo-generate-rpm). VERSION defaults to the program's own; a different
|
||||
# one repackages the same program under that version, which is how the
|
||||
# install-and-upgrade test gets its second release. FORMATS is
|
||||
# cargo-packager's list (default deb,appimage,pacman); the .rpm is always
|
||||
# built. Run from the repository root.
|
||||
#
|
||||
# The program is target/release/wanderbooks, or the file WB_PROGRAM names:
|
||||
# a package must run on the oldest system it claims, and a program linked
|
||||
# on a newer one (this machine's glibc) fails there with "GLIBC_2.43 not
|
||||
# found". CI builds on ubuntu-24.04; `just test-linux-packages` builds in a
|
||||
# Debian trixie container.
|
||||
set -euo pipefail
|
||||
own=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ledger-cli/Cargo.toml | head -n1)
|
||||
version=${1:-$own}
|
||||
formats=${2:-deb,appimage,pacman}
|
||||
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=$(mktemp --suffix=.toml)
|
||||
trap 'rm -f "$config"' EXIT
|
||||
# cargo-packager reads paths relative to the config file, which is in /tmp
|
||||
# here, so they are made absolute.
|
||||
sed "s/^version = \".*\"/version = \"$version\"/
|
||||
s#^out-dir = .*#out-dir = \"$PWD/$out\"#
|
||||
s#\"target/release\"#\"$PWD/$stage\"#
|
||||
s#\"deploy/packaging/#\"$PWD/deploy/packaging/#
|
||||
s#\"LICENSE-MIT\"#\"$PWD/LICENSE-MIT\"#" \
|
||||
deploy/packaging/packager.toml >"$config"
|
||||
# The AppImage tools are themselves AppImages; run them without FUSE.
|
||||
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||
cargo packager --config "$config" --formats "$formats" --release
|
||||
assets='assets = [
|
||||
{ source = "'$PWD/$stage'/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" },
|
||||
]'
|
||||
cargo generate-rpm -p crates/ledger-cli --set-metadata "version=\"$version\"" --set-metadata "$assets" -o "$out/"
|
||||
rm -rf "$stage"
|
||||
ls -l "$out"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1 @@
|
||||
aur
|
||||
@@ -0,0 +1 @@
|
||||
deb
|
||||
@@ -0,0 +1 @@
|
||||
rpm
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# A release folder on this machine, for trying `wanderbooks update` without
|
||||
# a network or the owner's keys (P11a step 2; docs/packaging.md).
|
||||
#
|
||||
# local-release.sh FILE VERSION OUTDIR
|
||||
#
|
||||
# Makes a throwaway signing key in OUTDIR/key (never a release key), signs
|
||||
# FILE with it through cargo-packager, copies it beside a manifest.json in
|
||||
# the shape `wanderbooks update` reads, and leaves the key's public half in
|
||||
# OUTDIR/release.pub. Then:
|
||||
#
|
||||
# wanderbooks update --feed file://OUTDIR/manifest.json \
|
||||
# --trusted-key OUTDIR/release.pub
|
||||
set -euo pipefail
|
||||
file=${1:?usage: local-release.sh FILE VERSION OUTDIR}
|
||||
version=${2:?usage: local-release.sh FILE VERSION OUTDIR}
|
||||
out=${3:?usage: local-release.sh FILE VERSION OUTDIR}
|
||||
mkdir -p "$out"
|
||||
if [ ! -f "$out/key" ]; then
|
||||
cargo packager signer generate --path "$out/key" --password "" --ci >/dev/null
|
||||
cp "$out/key.pub" "$out/release.pub"
|
||||
fi
|
||||
case "$(uname -m)" in arm64) arch=aarch64 ;; *) arch=$(uname -m) ;; esac
|
||||
platform="$(uname -s | tr '[:upper:]' '[:lower:]')-$arch"
|
||||
name="wanderbooks-$version-$platform"
|
||||
cp "$file" "$out/$name"
|
||||
cargo packager signer sign "$out/$name" --private-key "$out/key" --password "" >/dev/null
|
||||
sha=$(sha256sum "$out/$name" | cut -d' ' -f1)
|
||||
sig=$(tr -d '\n' <"$out/$name.sig")
|
||||
printf '{"version":"%s","notes":"a local release","platforms":{"%s":{"url":"%s","sha256":"%s","signature":"%s"}}}\n' \
|
||||
"$version" "$platform" "$name" "$sha" "$sig" >"$out/manifest.json"
|
||||
echo "feed: file://$(cd "$out" && pwd)/manifest.json key: $out/release.pub"
|
||||
@@ -0,0 +1,36 @@
|
||||
# cargo-packager's description of the Linux bundles (P11a step 3; D-25).
|
||||
# `just package-linux` builds the release program and runs
|
||||
# cargo packager --config deploy/packaging/packager.toml
|
||||
# from the repository root. The .rpm is cargo-generate-rpm's, from the
|
||||
# [package.metadata.generate-rpm] table of crates/ledger-cli/Cargo.toml.
|
||||
name = "wanderbooks"
|
||||
product-name = "Wanderbooks"
|
||||
version = "0.1.0"
|
||||
identifier = "ai.wanderbooks.app"
|
||||
description = "Double-entry bookkeeping your agent keeps and you can read."
|
||||
long-description = "One program: the viewer, the books server, and the bridge that lets Claude Code or Codex keep the books."
|
||||
homepage = "https://wanderbooks.ai"
|
||||
authors = ["Stephen Waits"]
|
||||
license-file = "LICENSE-MIT"
|
||||
category = "Finance"
|
||||
binaries-dir = "target/release"
|
||||
out-dir = "target/packages"
|
||||
icons = ["deploy/packaging/icons/512x512.png"]
|
||||
formats = ["deb", "appimage", "pacman"]
|
||||
binaries = [{ path = "wanderbooks", main = true }]
|
||||
|
||||
[deb]
|
||||
section = "utils"
|
||||
# A deb marks itself package-managed: `wanderbooks update` refuses on it
|
||||
# and says to use apt (plan 3.9).
|
||||
desktop-template = "deploy/packaging/wanderbooks.desktop"
|
||||
depends = ["libc6", "libgcc-s1"]
|
||||
|
||||
[deb.files]
|
||||
"deploy/packaging/install-method-deb" = "usr/share/wanderbooks/install-method"
|
||||
|
||||
[pacman]
|
||||
depends = ["gcc-libs", "glibc"]
|
||||
|
||||
[pacman.files]
|
||||
"deploy/packaging/install-method-aur" = "usr/share/wanderbooks/install-method"
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# The apt and dnf repository indexes (P11a step 3), served from GitHub
|
||||
# Pages (D-26). Unsigned until step 5 gives them the release key.
|
||||
#
|
||||
# repo-indexes.sh apt DIR DIR/pool/*.deb -> DIR/dists/stable/...
|
||||
# repo-indexes.sh dnf DIR DIR/*.rpm -> DIR/repodata/
|
||||
#
|
||||
# Every release stays in the pool: a downgrade is the package manager's
|
||||
# (plan 3.9). apt needs apt-utils (`apt-ftparchive`), dnf needs
|
||||
# createrepo_c.
|
||||
set -euo pipefail
|
||||
kind=${1:?usage: repo-indexes.sh apt|dnf DIR}
|
||||
dir=${2:?usage: repo-indexes.sh apt|dnf DIR}
|
||||
case "$kind" in
|
||||
apt)
|
||||
cd "$dir"
|
||||
arch=binary-amd64
|
||||
mkdir -p "dists/stable/main/$arch"
|
||||
apt-ftparchive packages pool >"dists/stable/main/$arch/Packages"
|
||||
gzip -9 -kf "dists/stable/main/$arch/Packages"
|
||||
apt-ftparchive \
|
||||
-o APT::FTPArchive::Release::Origin=wanderbooks \
|
||||
-o APT::FTPArchive::Release::Suite=stable \
|
||||
-o APT::FTPArchive::Release::Codename=stable \
|
||||
-o APT::FTPArchive::Release::Architectures=amd64 \
|
||||
-o APT::FTPArchive::Release::Components=main \
|
||||
release dists/stable >dists/stable/Release.tmp
|
||||
mv dists/stable/Release.tmp dists/stable/Release
|
||||
;;
|
||||
dnf)
|
||||
createrepo_c --update "$dir"
|
||||
;;
|
||||
*)
|
||||
echo "usage: repo-indexes.sh apt|dnf DIR" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
# linux_packages_install_and_upgrade (P11a step 3): in a clean Debian,
|
||||
# Fedora and Arch container, install release 0.1.0 from its package,
|
||||
# open the books as an ordinary user, upgrade to 0.1.1 as root, and check
|
||||
# that the books' head is unchanged, that the host the books were open
|
||||
# under restarted onto the new file, and that `wanderbooks update` refuses
|
||||
# (a package manager owns the program). The containers run with
|
||||
# seccomp=unconfined: the host refuses to run under a filter (host.rs
|
||||
# INVARIANT 2), and Docker's default profile is one. Run from the repository root after
|
||||
# `just test-linux-packages` built both releases (target/packages/).
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
old=0.1.0 new=0.1.1
|
||||
pk=$PWD/target/packages
|
||||
mount=(-v "$pk:/packages:ro")
|
||||
|
||||
# The steps every distro shares, run inside the container: `install` and
|
||||
# `upgrade` are shell snippets for that distro.
|
||||
common() { cat <<'INNER'
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
useradd -m tester
|
||||
as_tester() { su tester -c "$*"; }
|
||||
install_old
|
||||
test -x /usr/bin/wanderbooks
|
||||
grep -q . /usr/share/wanderbooks/install-method
|
||||
as_tester 'wanderbooks --help' >/dev/null 2>&1 || true
|
||||
as_tester 'wanderbooks setup' >/dev/null
|
||||
as_tester 'wanderbooks books create acme' >/dev/null
|
||||
head_of() { as_tester 'tail -n1 ~/.local/share/wanderbooks/books/acme/journal.jsonl | sha256sum'; }
|
||||
before=$(as_tester 'wanderbooks books list')
|
||||
head_before=$(head_of)
|
||||
echo "before: $before head $head_before"
|
||||
# The books open: a holder keeps the host and server running.
|
||||
as_tester 'nohup wanderbooks serve >/tmp/serve.log 2>&1 &'
|
||||
for _ in $(seq 50); do pgrep -u tester -x wanderbooks -a | grep -q ' host' && break; sleep 0.2; done
|
||||
host_before=$(pgrep -u tester -f 'wanderbooks host' | head -n1)
|
||||
test -n "$host_before"
|
||||
upgrade_new
|
||||
# The host notices the replaced file and restarts onto it.
|
||||
for _ in $(seq 150); do
|
||||
host_after=$(pgrep -u tester -f 'wanderbooks host' | head -n1 || true)
|
||||
[ -n "$host_after" ] && [ "$host_after" != "$host_before" ] && break
|
||||
sleep 0.2
|
||||
done
|
||||
test -n "$host_after" && test "$host_after" != "$host_before"
|
||||
! readlink "/proc/$host_after/exe" | grep -q deleted
|
||||
after=$(as_tester 'wanderbooks books list')
|
||||
echo "after: $after head $(head_of)"
|
||||
test "$before" = "$after"
|
||||
test "$head_before" = "$(head_of)"
|
||||
if as_tester 'wanderbooks update' >/tmp/update.out 2>&1; then echo "update did not refuse" >&2; exit 1; fi
|
||||
grep -q 'package manager' /tmp/update.out
|
||||
as_tester 'wanderbooks close' >/dev/null 2>&1 || true
|
||||
echo "OK"
|
||||
INNER
|
||||
}
|
||||
|
||||
run() { # name image install_old upgrade_new
|
||||
local name=$1 image=$2
|
||||
echo "== $name ($image)"
|
||||
docker run --rm --security-opt seccomp=unconfined -e DEBIAN_FRONTEND=noninteractive "${mount[@]}" "$image" bash -c "
|
||||
$3
|
||||
$4
|
||||
$(common)"
|
||||
}
|
||||
|
||||
# Debian and Fedora install from a repository built by repo-indexes.sh
|
||||
# (the one GitHub Pages serves), the new release added to it afterwards.
|
||||
mount+=(-v "$PWD/deploy/packaging:/tools:ro")
|
||||
run debian debian:trixie-slim \
|
||||
"apt-get update -qq && apt-get install -y -qq apt-utils procps >/dev/null
|
||||
mkdir -p /repo/apt/pool && cp /packages/$old/*.deb /repo/apt/pool/ && /tools/repo-indexes.sh apt /repo/apt
|
||||
echo 'deb [trusted=yes] file:///repo/apt stable main' >/etc/apt/sources.list.d/wanderbooks.list
|
||||
install_old() { apt-get update -qq && apt-get install -y -qq wanderbooks >/dev/null; }" \
|
||||
"upgrade_new() { cp /packages/$new/*.deb /repo/apt/pool/ && /tools/repo-indexes.sh apt /repo/apt && apt-get update -qq && apt-get install -y -qq --only-upgrade wanderbooks >/dev/null; }"
|
||||
|
||||
run fedora fedora:latest \
|
||||
"dnf install -y -q procps-ng util-linux createrepo_c >/dev/null
|
||||
mkdir -p /repo/dnf && cp /packages/$old/*.rpm /repo/dnf/ && /tools/repo-indexes.sh dnf /repo/dnf
|
||||
printf '[wanderbooks]\nname=wanderbooks\nbaseurl=file:///repo/dnf\ngpgcheck=0\n' >/etc/yum.repos.d/wanderbooks.repo
|
||||
install_old() { dnf install -y -q wanderbooks >/dev/null; }" \
|
||||
"upgrade_new() { cp /packages/$new/*.rpm /repo/dnf/ && /tools/repo-indexes.sh dnf /repo/dnf && dnf upgrade -y -q --refresh wanderbooks >/dev/null; }"
|
||||
|
||||
# Arch: the AUR's own PKGBUILD (deploy/packaging/aur), its source pointed at
|
||||
# the tarball just built instead of the release URL, makes the package as
|
||||
# an ordinary user.
|
||||
arch_build='build() { rm -rf /build/$1 && mkdir -p /build/$1 && cp /packages/$1/*.tar.gz /build/$1/ && cp /tools/aur/PKGBUILD /build/$1/PKGBUILD && sed -i "s/^pkgver=.*/pkgver=$1/; s|^source=.*|source=(wanderbooks_$1_x86_64.tar.gz)|" /build/$1/PKGBUILD && chown -R builder /build/$1 && su builder -c "cd /build/$1 && makepkg -f --nodeps >/dev/null 2>&1"; }'
|
||||
run arch archlinux:latest \
|
||||
"useradd -m builder; pacman -Sy --noconfirm --needed base-devel procps-ng >/dev/null 2>&1; $arch_build; install_old() { build $old; pacman -U --noconfirm /build/$old/*.pkg.tar.zst >/dev/null 2>&1; }" \
|
||||
"upgrade_new() { build $new; pacman -U --noconfirm /build/$new/*.pkg.tar.zst >/dev/null 2>&1; }"
|
||||
echo "all packages install and upgrade"
|
||||
@@ -0,0 +1,7 @@
|
||||
[Desktop Entry]
|
||||
Categories=Office;Finance;
|
||||
Comment=Double-entry bookkeeping your agent keeps and you can read.
|
||||
Exec=wanderbooks
|
||||
Name=Wanderbooks
|
||||
Terminal=false
|
||||
Type=Application
|
||||
@@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Categories={{categories}}
|
||||
{{#if comment}}
|
||||
Comment={{comment}}
|
||||
{{/if}}
|
||||
Exec={{exec}}
|
||||
Icon={{icon}}
|
||||
Name={{name}}
|
||||
Terminal=false
|
||||
Type=Application
|
||||
+41
-1
@@ -36,7 +36,7 @@ the specification in `bookspec/` (which never depends on the engine).
|
||||
| `ledger-import` | formats: QuickBooks CSV in; IIF, XLSX, Typst PDFs (statements, the invoice, labels) out | `ledger-core`, `ledger-reader` |
|
||||
| `ledger-package` | the shared read-only view over `Store` or `Snapshot` (`PackageBooks`): the CPA package, the 1099 worksheet, the invoice document, Needs attention | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-vault` |
|
||||
| `ledger-mcp` | the books server: the MCP tools (122), resources, the handbook, scopes, the door and the access log, backups' scheduling | all of the above |
|
||||
| `ledger-host` | the host and its leases, the agent bridge (`wanderbooks mcp`), tokens in 0600 files under the data root (D-63), the owner's commands, uploads and downloads | `ledger-store`, `ledger-mcp`, `ledger-config` |
|
||||
| `ledger-host` | the host and its leases, the agent bridge (`wanderbooks mcp`), tokens in 0600 files under the data root (D-63), the owner's commands, uploads and downloads, `update` and `rollback` (P11a) | `ledger-store`, `ledger-mcp`, `ledger-config` |
|
||||
| `ledger-gui` | the viewer: read-only, live, egui; the Books menu hands work to the server | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-package`, `ledger-vault`, `ledger-host` |
|
||||
| `ledger-cli` | `wanderbooks`, the one executable: every mode (`serve`, `server`, `host`, `mcp`, `gui`, `setup`, `books`, `token`, `connect`, `doctor`, …) and the over-the-wire test suites | everything |
|
||||
| `bookspec/checker`, `bookspec/runner` | the example schema and parser, the rule catalogue, the `Engine` trait; the per-rule runner | nothing of ours (the adapter in `ledger-core`'s tests depends on them) |
|
||||
@@ -313,3 +313,43 @@ still run on the server). The recipe, the head check for the move and the
|
||||
two-account CI job are in `deploy/README.md`, `deploy/wanderbooks-server.service`
|
||||
and `.github/workflows/two-user.yml`; the checks are
|
||||
`crates/ledger-cli/tests/two_user.rs`.
|
||||
|
||||
## Packaging and updates (`ledger-host`'s `update.rs`, `deploy/packaging/`, P11a)
|
||||
|
||||
One program, installed one of two ways, updated one of two ways
|
||||
(plan D-24). The install says which: a package ships
|
||||
`share/wanderbooks/install-method` beside `bin/`, and a file there makes
|
||||
the program package-managed; `wanderbooks update` and `rollback` refuse it
|
||||
and name the manager (apt, dnf and the AUR keep every release, so
|
||||
downgrading is theirs). Anything else (an AppImage, whose file is named by
|
||||
`$APPIMAGE`; an executable put somewhere by hand) updates itself:
|
||||
|
||||
- the feed is a `manifest.json` (a version, and per platform a file's
|
||||
address, sha256 and minisign signature; the shape `cargo-packager`'s
|
||||
updater reads, with the sha256 added) at GitHub Releases' fixed address
|
||||
(D-26); `--feed` reads another, and only with a `file://` feed may
|
||||
`--trusted-key` name the key;
|
||||
- the key a release is checked against is compiled in
|
||||
(`WANDERBOOKS_RELEASE_PUBKEY`, set by the release workflow once the
|
||||
owner's key exists; step 5): a build without one updates from nothing;
|
||||
- a download is accepted only if its sha256 matches AND its signature
|
||||
verifies; then it is staged, the running version kept under
|
||||
`<root>/updates/previous/` with the journal format it understood, and the
|
||||
file replaced by a rename of a complete copy made beside it;
|
||||
- nothing tells the books to stop. The host notices its executable's file
|
||||
was replaced and restarts as it does for a package upgrade (host.rs
|
||||
INVARIANT 7), finishing calls in progress; bridges reconnect upstream
|
||||
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).
|
||||
|
||||
`deploy/packaging/` holds the Linux packaging: `packager.toml`
|
||||
(cargo-packager: `.deb`, AppImage, the pacman tarball with its PKGBUILD),
|
||||
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.
|
||||
+133
-15
@@ -1,23 +1,141 @@
|
||||
# Packaging and updates: the trial (P11a step 1)
|
||||
|
||||
Status 2026-10-29: **the trial has not been run.** It needs clean Linux,
|
||||
macOS and Windows machines for the six formats (`.deb`, `.rpm`, AUR,
|
||||
AppImage, `.dmg`, NSIS `.exe`), and the one machine that built step 0 is a
|
||||
Linux desktop with neither a macOS nor a Windows target, no container
|
||||
runtime and none of the packager tools installed. The tools are pinned in
|
||||
`mise.toml` now (`cargo-packager` 0.11.8, `cargo-packager-updater` 0.2.3,
|
||||
`cargo-generate-rpm` 0.21.1), so the trial is `mise install` and the VMs.
|
||||
Nothing below is a result; it is what is known before the trial, and the
|
||||
table is filled by the person who runs it.
|
||||
Status 2026-10-29: **the Linux half of the trial is run** (on the Arch
|
||||
desktop that builds the project, with Debian, Fedora and Arch containers
|
||||
for the installs); **the macOS and Windows halves are not**: they need
|
||||
those machines (and for the `.dmg` and the NSIS `.exe`, the signing
|
||||
identities of step 5), and this machine has neither a macOS nor a Windows
|
||||
target. `cargo-packager` 0.11.8 and `cargo-generate-rpm` 0.21.1 are pinned in
|
||||
`mise.toml`; `cargo-packager-updater` is **not** pinned: it is a library
|
||||
(the program checks releases itself, `crates/ledger-host/src/update.rs`),
|
||||
not a tool, so there is nothing for a recipe to call.
|
||||
|
||||
| format | built by | installs as | updated by | signed by |
|
||||
|---|---|---|---|---|
|
||||
| `.deb` | `cargo packager` | (trial) | apt repository (step 3) | (trial) |
|
||||
| `.rpm` | `cargo-generate-rpm` | (trial) | dnf repository (step 3) | (trial) |
|
||||
| AUR `PKGBUILD` | hand-written | (trial) | the package manager | n/a |
|
||||
| AppImage | `cargo packager` | (trial) | `cargo-packager-updater` | (trial) |
|
||||
| `.dmg` | `cargo packager` | (trial) | `cargo-packager-updater` | Developer ID (step 5) |
|
||||
| NSIS `.exe` | `cargo packager` | (trial) | `cargo-packager-updater` | Windows identity (step 5) |
|
||||
| `.deb` | `cargo packager` (`deb`) | `/usr/bin/wanderbooks`, a menu entry, `/usr/share/wanderbooks/install-method` = `deb`; 47 MB | apt repository (`repo-indexes.sh apt`, GitHub Pages) | unsigned until step 5 (the repository's index is `trusted=yes` in the test) |
|
||||
| `.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) |
|
||||
|
||||
`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
|
||||
loaded 16-core machine); `just test-linux-packages` is the install-and-upgrade test.
|
||||
|
||||
## Linux results (2026-10-29)
|
||||
|
||||
What the trial found, each of which is built around now:
|
||||
|
||||
1. **A program linked here does not start on Debian.** This machine's
|
||||
glibc is 2.44; the binary asks for `GLIBC_2.43` and Debian 13 (trixie)
|
||||
has 2.41. A package must be built on the oldest system it claims.
|
||||
`.github/workflows/release.yml` builds on `ubuntu-24.04` (glibc 2.39);
|
||||
`just release-trixie` builds in `rust:1.99-trixie` for the local test. The
|
||||
claim is therefore "Debian 13, Ubuntu 24.04 and newer, current Fedora and
|
||||
Arch"; Debian 12 and Ubuntu 22.04 are not claimed (they would need a
|
||||
build container of their own).
|
||||
2. **The books refuse to run under a seccomp filter** (`host.rs`
|
||||
INVARIANT 2), and Docker's default profile is one: in a container the
|
||||
host fails to start ("refusing to run under a seccomp filter"). The
|
||||
tests run containers with `--security-opt seccomp=unconfined`. Anyone
|
||||
running the books in Docker meets this; it is not a bug of the packages.
|
||||
3. **An AppImage's launcher runs the desktop file's `Exec` line and appends
|
||||
the arguments it was given.** With `Exec=wanderbooks gui`,
|
||||
`Wanderbooks.AppImage update` ran `wanderbooks gui update`. The desktop
|
||||
files say plain `Exec=wanderbooks`, and a launch with nothing to say and
|
||||
no terminal is the viewer (`main.rs::is_bare_launch`); at a terminal
|
||||
it is still the usage. So the AppImage is also the command line:
|
||||
`Wanderbooks.AppImage update`, `... mcp --book acme`.
|
||||
4. **cargo-packager's configuration** (0.11.8): paths in a config are
|
||||
relative to the *config file*, not the working directory (the build script
|
||||
writes an absolute-path copy); `files` maps *source to destination*;
|
||||
`category` is a fixed list (`Finance`); the AppImage needs a square icon
|
||||
(`deploy/packaging/icons/512x512.png`, **a placeholder the owner should
|
||||
replace**); the `pacman` format yields the tarball and PKGBUILD but not a
|
||||
package (that is `makepkg`'s); its `.deb` has `Depends` from the config.
|
||||
5. **The AppImage tools are downloaded when it is built** (`AppRun`,
|
||||
`linuxdeploy` and its AppImage plugin from tauri-apps' and linuxdeploy's
|
||||
GitHub releases, unverified by cargo-packager): a supply-chain edge of
|
||||
the release build, not of the installed app. They run with
|
||||
`APPIMAGE_EXTRACT_AND_RUN=1`, so the build needs no FUSE.
|
||||
6. **`cargo packager signer` signs in a format `update` verifies.** Its
|
||||
key and its `.sig` are the base64 of minisign's two-line text; the
|
||||
manifest's `signature` is that `.sig` as written, and `update` reads the
|
||||
public key in either form. Checked end to end by hand (below), and by
|
||||
`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
|
||||
(`/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`).
|
||||
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
|
||||
opens only an existing book (`ledger_gui::book_in_root`), and a data root
|
||||
and a first book come from `wanderbooks setup` and `books create`, which
|
||||
need a terminal. A first-run path in the viewer ("New books…" is in its
|
||||
menu, but the menu is not reachable until a book is open) is not in P11a's
|
||||
steps. It is the gap between this wave's AppImage and Done-when (1).
|
||||
|
||||
By hand, on this machine, against a throwaway release folder made by `just
|
||||
local-release` (a key from `cargo packager signer generate`; the feed
|
||||
`file://…/manifest.json`; a scratch home made by `wanderbooks setup --home`,
|
||||
nothing of the owner's):
|
||||
|
||||
```text
|
||||
Wanderbooks.AppImage --help -> the usage (FUSE mount, and with APPIMAGE_EXTRACT_AND_RUN=1)
|
||||
Wanderbooks.AppImage setup --home H -> the private folder; books create acme -> books list: "acme open"
|
||||
Wanderbooks.AppImage update --check --feed F --trusted-key K
|
||||
-> "wanderbooks 0.2.0 is available (this is 0.1.0)"
|
||||
Wanderbooks.AppImage update --feed F --trusted-key K
|
||||
-> "Updated wanderbooks from 0.1.0 to 0.2.0."; the file's sha256
|
||||
is the release's; updates/previous/ holds the 0.1.0 image
|
||||
Wanderbooks.AppImage rollback -> "Went back to wanderbooks 0.1.0."; the file is the original again
|
||||
```
|
||||
|
||||
`linux_packages_install_and_upgrade` (`deploy/packaging/test-linux-packages.sh`),
|
||||
2026-10-29, 0.1.0 then 0.1.1, all three passing: in `debian:trixie-slim`
|
||||
and `fedora:latest` the package installs from a repository made by
|
||||
`repo-indexes.sh` (the release workflow runs the same script), in
|
||||
`archlinux:latest` from `makepkg` over `deploy/packaging/aur/PKGBUILD`; then
|
||||
books are made as an ordinary user and held open, 0.1.1 is added and
|
||||
upgraded to by the package manager (`apt-get install --only-upgrade`, `dnf
|
||||
upgrade`, `pacman -U`), the host restarts onto the new file (a new pid whose
|
||||
`/proc/PID/exe` is not deleted), the journal's last line (the head) is
|
||||
byte-for-byte unchanged, and `wanderbooks update` exits non-zero naming the
|
||||
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).
|
||||
|
||||
## What `update` and `rollback` need from a release
|
||||
|
||||
`manifest.json` at `releases/latest/download/` (D-26):
|
||||
|
||||
```json
|
||||
{ "version": "0.2.0", "notes": "…",
|
||||
"platforms": { "linux-x86_64": { "url": "wanderbooks-0.2.0-linux-x86_64",
|
||||
"sha256": "…hex…", "signature": "…base64 of the .sig file…" } } }
|
||||
```
|
||||
|
||||
`url` is absolute or relative to the manifest; the platform key is
|
||||
`std::env::consts::OS-ARCH`; the file is the whole program (the AppImage
|
||||
on Linux). The program checks the version is higher, the sha256, and the
|
||||
minisign signature against `WANDERBOOKS_RELEASE_PUBKEY` (compiled in by the
|
||||
release workflow once the owner has a key: **step 5, Q-4**; today no build
|
||||
carries one, so a build updates only from a `file://` feed with
|
||||
`--trusted-key`). The two-key release manifest of D-25 would wrap this
|
||||
(rotation, expiry, a monotone version) and is step 5's.
|
||||
|
||||
## What step 0 found about the platforms (read before the macOS and Windows rows)
|
||||
|
||||
|
||||
@@ -3669,3 +3669,79 @@ it); the digest's `since_seq` comparison (`>`), its `as_of` bound, the
|
||||
`zero_net_accounts` branch and the `never_reconciled` split;
|
||||
`report_status`'s three open conditions; the open-drafts refusal's
|
||||
`<= as_of` and its decision; the attention `awaiting_evidence` branch.
|
||||
|
||||
## P11a steps 2 and 3: update, rollback, the Linux packages (2026-10-29)
|
||||
|
||||
`wanderbooks update` and `rollback` (`ledger-host/src/update.rs`) are
|
||||
tested over a local release folder (a `file://` feed) and a minisign key
|
||||
the test makes, with real programs: each test makes a home, a stripped
|
||||
copy of the program in its `bin/`, and a manifest naming a newer copy of
|
||||
it (the same program with a tail, so its hash differs). All in
|
||||
`crates/ledger-cli/tests/update.rs` (Linux only):
|
||||
|
||||
- `an_update_replaces_the_program_keeps_the_old_one_and_rollback_brings_it_back`:
|
||||
no key and no feed is refused ("no release key"); the same version is "the
|
||||
newest"; an older offer is refused and nothing is kept; an update puts
|
||||
the new bytes in place, keeps the old under `updates/previous/` with the
|
||||
format it understood, leaves no temporary or staged file; rollback puts
|
||||
the old bytes back and consumes what was kept; a second rollback says
|
||||
there is nothing to go back to.
|
||||
- `update_is_atomic`: a reader polling the program file all through the
|
||||
update sees, on every read, the old file whole or the new whole.
|
||||
- `failed_update_rolls_back`: a validly signed file that does not start
|
||||
(`exit 3`) is swapped in, fails `--help`, and the old program is put
|
||||
back; nothing is staged or left beside the program.
|
||||
- `unsigned_update_refused`: five bad manifests (a good signature by
|
||||
another key; a signature of other bytes; a sha256 that differs; an empty
|
||||
signature; a signature that is not one), each refused with the program
|
||||
unchanged and nothing kept or staged; `--trusted-key` with an `https`
|
||||
feed is refused.
|
||||
- `package_managed_install_refuses_update`: a
|
||||
`share/wanderbooks/install-method` marker beside `bin/` makes both
|
||||
commands refuse, naming the manager, with nothing made in the data root.
|
||||
- `rollback_refuses_after_a_book_consented_to_a_newer_format`: with a
|
||||
book that declares this version's format and a kept version that knows
|
||||
one feature less, rollback refuses and the program stays; with the kept
|
||||
version knowing everything the book declares, it goes back.
|
||||
- `bridge_survives_update` and `update_waits_for_a_running_call`: an
|
||||
agent's bridge session writes without pause while `update` runs and the
|
||||
host restarts onto the new file; every write is answered and made once
|
||||
(the account is in the chart exactly once), the session needs no
|
||||
reconnect, its tool list is unchanged, and the host and server run the
|
||||
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`).
|
||||
|
||||
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`,
|
||||
`a_swap_leaves_the_whole_new_file_and_no_temporary`,
|
||||
`a_public_key_is_read_in_every_form_minisign_and_the_packager_write_it`.
|
||||
`main.rs`: `only_nothing_at_all_with_no_terminal_is_the_viewer` (the bare
|
||||
launch an AppImage's launcher makes; it cannot be run as a test, because the
|
||||
viewer finds the account's real data root from the password database).
|
||||
|
||||
Not tests, run by hand on this machine and recorded in `docs/packaging.md`:
|
||||
the AppImage updating and rolling back itself against a feed signed by
|
||||
`cargo packager signer` (`just local-release`), which is the check that the
|
||||
packager's signature format is the one `update` verifies.
|
||||
|
||||
**`linux_packages_install_and_upgrade`** is `deploy/packaging/test-linux-packages.sh`
|
||||
(`just test-linux-packages`, needs docker; not part of `just check`): in a
|
||||
clean Debian, Fedora and Arch container it installs release 0.1.0 (Debian
|
||||
and Fedora from an apt and a dnf repository made by `repo-indexes.sh`, the
|
||||
same script the release workflow runs; Arch by `makepkg` from the
|
||||
packager's PKGBUILD), makes books as an ordinary user, holds them open,
|
||||
adds 0.1.1 to the repository and upgrades; then checks the host restarted
|
||||
onto the new file, `wanderbooks books list` is unchanged, and
|
||||
`wanderbooks update` refuses and names the package manager. The programs
|
||||
are built in a `rust:1.99-trixie` container (`just release-trixie`):
|
||||
see `docs/packaging.md` for why the build machine's own binary is not used.
|
||||
|
||||
**For P12 (mutation targets, none run, D-33):** `update.rs`'s `verify`
|
||||
(a mutant that drops the sha check or the signature check must die on
|
||||
`unsigned_update_refused`'s sha and key cases), `refuse_if_consented`'s
|
||||
two comparisons (`rollback_refuses_after_a_book_consented_to_a_newer_format`
|
||||
covers the feature one; a case with a higher format number is a gap to
|
||||
add), `swap_in`'s temporary-then-rename (`update_is_atomic`), `healthy`
|
||||
(`failed_update_rolls_back`), `install_kind` (the marker test), and
|
||||
`parse_version`'s rejections.
|
||||
@@ -263,3 +263,35 @@ deploy: site
|
||||
# Deploy a preview of the website and print its URL
|
||||
deploy-preview: site
|
||||
{{netlify}} deploy --dir site/dist --no-build
|
||||
|
||||
# The Linux packages (P11a step 3), in target/packages/VERSION. Builds the
|
||||
# release program first; needs the tools `mise install` pins. A VERSION other
|
||||
# than the program's repackages the same program under it (the upgrade
|
||||
# test's second release).
|
||||
# Build the AppImage, .deb, pacman tarball and .rpm
|
||||
package-linux version="" formats="deb,appimage,pacman":
|
||||
cargo build --release -p ledger-cli
|
||||
deploy/packaging/build-linux.sh {{version}} {{formats}}
|
||||
|
||||
# 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
|
||||
local-release file version dir:
|
||||
deploy/packaging/local-release.sh {{file}} {{version}} {{dir}}
|
||||
|
||||
# A program linked here does not start on Debian (docs/packaging.md); this
|
||||
# builds it in rust:1.99-trixie, into target/container. Needs docker.
|
||||
# Build the release program in a Debian trixie container
|
||||
release-trixie:
|
||||
mkdir -p target/container
|
||||
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -e CARGO_HOME=/tmp/cargo-home -e CARGO_TARGET_DIR=/target -v "$PWD:/src:ro" -v "$PWD/target/container:/target" -w /src rust:1.99-trixie cargo build --release --locked -p ledger-cli
|
||||
|
||||
# Install each Linux package in a clean Arch, Debian and Fedora container,
|
||||
# open the books, upgrade to the next release, and check the books' head is
|
||||
# unchanged and the open books restarted onto the new program. Builds the
|
||||
# program in a trixie container, then releases 0.1.0 and 0.1.1 of it.
|
||||
# Install and upgrade the Linux packages in Arch, Debian and Fedora containers
|
||||
test-linux-packages: release-trixie
|
||||
WB_PROGRAM=target/container/release/wanderbooks deploy/packaging/build-linux.sh 0.1.0 deb,pacman
|
||||
WB_PROGRAM=target/container/release/wanderbooks deploy/packaging/build-linux.sh 0.1.1 deb,pacman
|
||||
deploy/packaging/test-linux-packages.sh
|
||||
@@ -23,3 +23,7 @@ node = "26"
|
||||
# A compile cache shared by every jj workspace's target dir: point
|
||||
# cargo's build.rustc-wrapper at it (~/.cargo/config.toml).
|
||||
sccache = "0.18.0"
|
||||
# `just package-linux` (docs/packaging.md): the AppImage and .deb by
|
||||
# cargo-packager, the .rpm by cargo-generate-rpm (D-25).
|
||||
"cargo:cargo-packager" = "0.11.8"
|
||||
"cargo:cargo-generate-rpm" = "0.21.1"
|
||||
+1
-1
@@ -1638,7 +1638,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 and 1 only, 2026-10-29; steps 2 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**: 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.
|
||||
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