The host has staged a downloaded, signature-checked update since step 4, but the only way to apply it was `wanderbooks update` at a terminal, and nothing in the viewer or on an agent's first call said one waited. The owner's accept is the name plan 8.0.3 allocated for it: the lease socket gains `Intent::ApplyUpdate`, and the host installs the staged copy from a thread and lets its existing watch for a replaced program (host.rs INVARIANT 7) restart onto it once no call runs, so the books stay open and every holder's session carries on. With no host running there is no call to cut off, so the viewer applies it itself. The viewer reads `<root>/updates/staged/meta.json` every few seconds and shows a standing notice with "Update now"; Books gains "Check for updates" (which looks at the feed at once, whatever the daily look last did) and "Update to X". `books.start` gains the allocated `update_waiting` field and a next step telling the agent to tell the human; no tool can accept. `apply_staged` waits up to five seconds for the update lock: running the new tests showed the host's own look can hold it for a moment after it writes `meta.json`, and the owner's accept should wait that out rather than answer "nothing waits". Tests: `books_start_says_an_update_waits` and `the_owner_accepts_a_staged_update_in_the_app` in `crates/ledger-cli/tests/update.rs` (a bridge writes all through the restart, each account in the chart exactly once); `the_menu_checks_for_updates_and_a_waiting_update_shows_a_notice_that_accepts` and `books_outside_a_data_root_have_no_update_notice` in `crates/ledger-gui/tests/update_notice.rs`.
1025 lines
40 KiB
Rust
1025 lines
40 KiB
Rust
//! `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: on the
|
|
//! command line, or in `<root>/updates/feed` (the feed's address on
|
|
//! the first line, the key file's path on the second).
|
|
//! 9. The host looks for a newer release at most once a day while the
|
|
//! books are open, and only downloads and verifies it (the same
|
|
//! checks as INVARIANT 1) into `<root>/updates/staged/`. It never
|
|
//! swaps: a staged update is applied only (a) by `wanderbooks
|
|
//! update`, the owner's say-so, or (b) by an opener just before it
|
|
//! starts a host, when no host is running, so no call is ever cut
|
|
//! off. A staged file is checked again against the key before it is
|
|
//! applied; one that fails, or does not start, is dropped.
|
|
//! SCENARIOS:
|
|
//! 1. IF the feed offers a higher version, signed by our key: SHOULD
|
|
//! replace the program and keep the old one.
|
|
//! 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.
|
|
//! 5. IF the host's daily look finds a signed newer release: SHOULD
|
|
//! stage it and change nothing running; the next host start runs it.
|
|
//! 6. IF the daily look finds a bad signature or checksum: SHOULD stage
|
|
//! nothing.
|
|
//! ```
|
|
|
|
use serde::Deserialize;
|
|
use 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 STAGED: &str = "staged";
|
|
const FEED_FILE: &str = "feed";
|
|
const CHECKED: &str = "checked";
|
|
|
|
/// The version of the program being run, set by its `main` (this crate's
|
|
/// own is the fallback: the packages rewrite only the program's).
|
|
static PROGRAM_VERSION: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
|
|
|
|
/// Say which version this program is.
|
|
pub fn set_program_version(version: &'static str) {
|
|
let _ = PROGRAM_VERSION.set(version);
|
|
}
|
|
|
|
/// This program's version.
|
|
pub fn program_version() -> &'static str {
|
|
PROGRAM_VERSION
|
|
.get()
|
|
.copied()
|
|
.unwrap_or(env!("CARGO_PKG_VERSION"))
|
|
}
|
|
|
|
const MAX_MANIFEST: usize = 1 << 20;
|
|
const MAX_PROGRAM: u64 = 1 << 30;
|
|
/// 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())
|
|
}
|
|
|
|
/// How the running program was installed.
|
|
pub 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 found_root = ledger_store::root::Root::find(flags.root.as_deref()).ok();
|
|
let local = found_root
|
|
.as_ref()
|
|
.and_then(|r| local_feed(&r.path().join(UPDATES)));
|
|
let (feed, key_file) = match (&flags.feed, &local) {
|
|
(Some(feed), _) => (feed.clone(), flags.key_file.clone()),
|
|
(None, Some((feed, key))) => (feed.clone(), Some(key.clone())),
|
|
(None, None) => (DEFAULT_FEED.to_string(), flags.key_file.clone()),
|
|
};
|
|
let feed = feed.as_str();
|
|
let key = trusted_key(feed, key_file.as_deref())?;
|
|
let manifest = fetch_manifest(feed)?;
|
|
let offered = parse_version(&manifest.version).ok_or_else(|| {
|
|
format!(
|
|
"the manifest's version {:?} isn't one of ours",
|
|
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 {
|
|
let ready = found_root
|
|
.as_ref()
|
|
.and_then(|r| staged_bytes(&r.path().join(UPDATES), &manifest.version))
|
|
.is_some();
|
|
println!(
|
|
"wanderbooks {} is available{} (this is {current}). Run `{}` to install it.",
|
|
manifest.version,
|
|
if ready { ", already downloaded" } else { "" },
|
|
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_within(&updates, Duration::from_secs(10))?;
|
|
|
|
// A copy the host already downloaded and verified is used as it is
|
|
// found (and verified again below); else the download.
|
|
let bytes = match staged_bytes(&updates, &manifest.version) {
|
|
Some(bytes) => bytes,
|
|
None => fetch(&resolve(feed, &entry.url)?, MAX_PROGRAM)?,
|
|
};
|
|
verify(&bytes, entry, &key)?;
|
|
|
|
// Verified: from here the files are ours to write. Staged, then the
|
|
// 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);
|
|
let _ = std::fs::remove_dir_all(updates.join(STAGED));
|
|
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(())
|
|
}
|
|
|
|
/// The manifest at `feed`.
|
|
fn fetch_manifest(feed: &str) -> Result<Manifest, Failure> {
|
|
let bytes = fetch(feed, MAX_MANIFEST as u64)?;
|
|
serde_json::from_slice(&bytes)
|
|
.map_err(|e| format!("the release manifest isn't one we can read: {e}").into())
|
|
}
|
|
|
|
/// The feed the owner named for this root (INVARIANT 8): a `file://`
|
|
/// address, and the file of the key to check it against.
|
|
fn local_feed(updates: &Path) -> Option<(String, PathBuf)> {
|
|
let text = std::fs::read_to_string(updates.join(FEED_FILE)).ok()?;
|
|
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
|
|
let feed = lines.next()?;
|
|
let key = lines.next()?;
|
|
feed.starts_with("file://")
|
|
.then(|| (feed.to_string(), PathBuf::from(key)))
|
|
}
|
|
|
|
/// The staged program, if it is the one for `version` and whole.
|
|
fn staged_bytes(updates: &Path, version: &str) -> Option<Vec<u8>> {
|
|
let dir = updates.join(STAGED);
|
|
let meta: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(dir.join(META)).ok()?).ok()?;
|
|
if meta["version"].as_str() != Some(version) {
|
|
return None;
|
|
}
|
|
let bytes = std::fs::read(dir.join(PROGRAM)).ok()?;
|
|
sha256_hex(&bytes)
|
|
.eq_ignore_ascii_case(meta["sha256"].as_str()?)
|
|
.then_some(bytes)
|
|
}
|
|
|
|
/// The version staged, if any.
|
|
pub fn staged_version(root: &ledger_store::root::Root) -> Option<String> {
|
|
let meta = std::fs::read(root.path().join(UPDATES).join(STAGED).join(META)).ok()?;
|
|
let meta: serde_json::Value = serde_json::from_slice(&meta).ok()?;
|
|
meta["version"].as_str().map(str::to_string)
|
|
}
|
|
|
|
/// INVARIANT 9, the host's daily look: stage a newer release if there is
|
|
/// one and `every` has passed since the last look. Returns the version
|
|
/// newly staged. Changes nothing that runs.
|
|
pub fn stage_if_due(
|
|
root: &ledger_store::root::Root,
|
|
current: &str,
|
|
every: Duration,
|
|
) -> Result<Option<String>, Failure> {
|
|
let Install::SelfUpdating(_) = this_install()? else {
|
|
return Ok(None);
|
|
};
|
|
let updates = updates_dir(root)?;
|
|
let checked = updates.join(CHECKED);
|
|
if std::fs::metadata(&checked)
|
|
.and_then(|m| m.modified())
|
|
.ok()
|
|
.and_then(|t| t.elapsed().ok())
|
|
.is_some_and(|age| age < every)
|
|
{
|
|
return Ok(None);
|
|
}
|
|
// Marked first: a failed look waits a day too.
|
|
std::fs::write(&checked, b"").map_err(|e| format!("can't note the look: {}", e.kind()))?;
|
|
let current_v = parse_version(current).ok_or("this program's version isn't one of ours")?;
|
|
let (feed, key_file) = match local_feed(&updates) {
|
|
Some((feed, key)) => (feed, Some(key)),
|
|
None => (DEFAULT_FEED.to_string(), None),
|
|
};
|
|
let key = trusted_key(&feed, key_file.as_deref())?;
|
|
let Ok(_lock) = take_lock(&updates) else {
|
|
return Ok(None);
|
|
};
|
|
let manifest = fetch_manifest(&feed)?;
|
|
let offered =
|
|
parse_version(&manifest.version).ok_or("the manifest's version isn't one of ours")?;
|
|
if offered <= current_v {
|
|
let _ = std::fs::remove_dir_all(updates.join(STAGED));
|
|
return Ok(None);
|
|
}
|
|
if staged_bytes(&updates, &manifest.version).is_some() {
|
|
return Ok(None);
|
|
}
|
|
let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH);
|
|
let entry = manifest
|
|
.platforms
|
|
.get(&platform)
|
|
.ok_or_else(|| format!("release {} has no build for {platform}", manifest.version))?;
|
|
let bytes = fetch(&resolve(&feed, &entry.url)?, MAX_PROGRAM)?;
|
|
verify(&bytes, entry, &key)?;
|
|
let dir = updates.join(STAGED);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
private_dir(&dir)?;
|
|
write_program(&dir.join(PROGRAM), &bytes)?;
|
|
let meta = serde_json::json!({
|
|
"version": manifest.version,
|
|
"sha256": sha256_hex(&bytes),
|
|
"signature": entry.signature,
|
|
"notes": manifest.notes,
|
|
});
|
|
std::fs::write(dir.join(META), meta.to_string())
|
|
.map_err(|e| format!("can't stage the update: {}", e.kind()))?;
|
|
Ok(Some(manifest.version))
|
|
}
|
|
|
|
/// INVARIANT 9, applying: swap in the staged program, if there is one
|
|
/// for a newer version. The caller guarantees no host is running (it is
|
|
/// about to start one) or is the owner running `update`. Returns the
|
|
/// version applied.
|
|
pub fn apply_staged(
|
|
root: &ledger_store::root::Root,
|
|
current: &str,
|
|
) -> Result<Option<String>, Failure> {
|
|
let updates = root.path().join(UPDATES);
|
|
let dir = updates.join(STAGED);
|
|
if !dir.join(META).is_file() {
|
|
return Ok(None);
|
|
}
|
|
let Install::SelfUpdating(target) = this_install()? else {
|
|
return Ok(None);
|
|
};
|
|
// The host's own look at the feed can still hold the lock for a moment
|
|
// after it writes `meta.json`; the owner's accept should wait that out,
|
|
// not answer "nothing waits" (P11a step 4).
|
|
let Ok(_lock) = take_lock_within(&updates, Duration::from_secs(5)) else {
|
|
return Ok(None);
|
|
};
|
|
let meta: serde_json::Value = std::fs::read(dir.join(META))
|
|
.ok()
|
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
|
.unwrap_or_default();
|
|
let drop_staged = || {
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
};
|
|
let (Some(version), Some(sha256), Some(signature)) = (
|
|
meta["version"].as_str(),
|
|
meta["sha256"].as_str(),
|
|
meta["signature"].as_str(),
|
|
) else {
|
|
drop_staged();
|
|
return Ok(None);
|
|
};
|
|
let current_v = parse_version(current).ok_or("this program's version isn't one of ours")?;
|
|
if parse_version(version).is_none_or(|v| v <= current_v) {
|
|
drop_staged();
|
|
return Ok(None);
|
|
}
|
|
let local = local_feed(&updates);
|
|
let key = trusted_key(
|
|
local.as_ref().map_or(DEFAULT_FEED, |(f, _)| f.as_str()),
|
|
local.as_ref().map(|(_, k)| k.as_path()),
|
|
)?;
|
|
let Some(bytes) = staged_bytes(&updates, version) else {
|
|
drop_staged();
|
|
return Err("the staged update is damaged: dropped".into());
|
|
};
|
|
let entry = Entry {
|
|
url: String::new(),
|
|
sha256: sha256.to_string(),
|
|
signature: signature.to_string(),
|
|
};
|
|
if let Err(e) = verify(&bytes, &entry, &key) {
|
|
drop_staged();
|
|
return Err(e);
|
|
}
|
|
drop(bytes);
|
|
keep_previous(&updates, &target, current)?;
|
|
swap_in(&target, &dir.join(PROGRAM))?;
|
|
if let Err(why) = healthy(&target) {
|
|
let back = swap_in(&target, &updates.join(PREVIOUS).join(PROGRAM));
|
|
drop_staged();
|
|
return Err(match back {
|
|
Ok(()) => format!("the staged version {version} didn't start ({why}); the old one is back"),
|
|
Err(e) => format!("the staged version {version} didn't start ({why}) and the old one couldn't be put back ({e})"),
|
|
}
|
|
.into());
|
|
}
|
|
drop_staged();
|
|
Ok(Some(version.to_string()))
|
|
}
|
|
|
|
/// `wanderbooks rollback`: go back to the version the last update
|
|
/// replaced.
|
|
pub fn rollback(prog: &str, args: impl IntoIterator<Item = String>) -> Result<(), Failure> {
|
|
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_within(&updates, Duration::from_secs(10))?;
|
|
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> {
|
|
take_lock_within(updates, Duration::ZERO)
|
|
}
|
|
|
|
/// The same, waiting up to `wait` for another to finish (the host's
|
|
/// look at the feed is a short holder).
|
|
fn take_lock_within(updates: &Path, wait: Duration) -> Result<std::fs::File, Failure> {
|
|
let deadline = Instant::now() + wait;
|
|
loop {
|
|
match take_lock_once(updates) {
|
|
Err(e) if Instant::now() < deadline && e.to_string().contains("running") => {
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
other => return other,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn take_lock_once(updates: &Path) -> Result<std::fs::File, Failure> {
|
|
let file = std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.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:?}");
|
|
}
|
|
}
|