diff --git a/Cargo.lock b/Cargo.lock index b23d490..c7208b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -667,6 +667,16 @@ dependencies = [ "windows-link", ] +[[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", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -862,6 +872,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ct-codecs" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd9f3db6f2cfef61c10613071955154ffdc9e515daebff26de4b54e35038fdd" + [[package]] name = "cursor-icon" version = "1.2.0" @@ -906,6 +922,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -1461,6 +1478,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "fieldcraft-update" +version = "0.5.8" +dependencies = [ + "flate2", + "log", + "minisign", + "minisign-verify", + "rustls", + "serde_json", + "sha2 0.11.1", + "tar", + "thiserror 2.0.21", + "ureq", + "zip", +] + [[package]] name = "fieldcraft-web" version = "0.5.8" @@ -1480,6 +1514,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -1652,8 +1696,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "wasm-bindgen", ] [[package]] @@ -1840,6 +1886,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 = "http" version = "1.5.0" @@ -1921,6 +1976,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2171,6 +2235,24 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minisign" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aefd422a7a8b5efced01cd7cdf3771e62ebecbc90e3d27695002d3af6af94586" +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" @@ -2727,6 +2809,16 @@ dependencies = [ "windows-link", ] +[[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", +] + [[package]] name = "peniko" version = "0.6.1" @@ -3219,6 +3311,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3346,6 +3459,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3376,6 +3498,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", + "salsa20", + "sha2 0.10.9", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3835,6 +3968,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" diff --git a/Cargo.toml b/Cargo.toml index 740c605..8bac640 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ fieldcraft-testkit = { path = "crates/testkit" } fieldcraft-render = { path = "crates/render" } fieldcraft-ui-egui = { path = "crates/ui-egui" } fieldcraft-mcp = { path = "crates/mcp" } +fieldcraft-update = { path = "crates/update" } # foundation thiserror = "2" diff --git a/crates/update/Cargo.toml b/crates/update/Cargo.toml new file mode 100644 index 0000000..0794cff --- /dev/null +++ b/crates/update/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "fieldcraft-update" +description = "FieldCraft's updates: check GitHub Releases for a newer version, download and verify it (SHA-256 list signed with the release key), and swap it in" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[features] +# A local HTTP server that plays the GitHub Releases API, for tests (`mock`). +mock = [] + +[dependencies] +serde_json = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } +sha2 = { workspace = true } +# Checks the release key's signature of SHA256SUMS.txt (Ed25519, minisign's format). MIT. +minisign-verify = "0.3" + +# Downloading and unpacking: the desktop app and the CLI only, never the browser build. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# HTTPS with rustls on ring, checked against the operating system's certificates: the same +# client the app's usage sending uses (apps/fieldcraft). +ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +zip = { workspace = true } +flate2 = "1" +tar = { version = "0.4", default-features = false } + +[dev-dependencies] +# Signs test checksum lists with throwaway keys (never the release key). +minisign = "0.9" diff --git a/crates/update/src/install.rs b/crates/update/src/install.rs new file mode 100644 index 0000000..60c04c9 --- /dev/null +++ b/crates/update/src/install.rs @@ -0,0 +1,409 @@ +//! How FieldCraft is installed here, which release asset updates it, and the plan that swaps a +//! verified, staged copy in: pure functions of the facts they are given, so every platform's +//! plan is tested on every platform. +//! +//! | Install | Asset | Swap | +//! |---|---|---| +//! | macOS `FieldCraft.app` | `fieldcraft-V-macos-universal.dmg` | the bundle, then `open -n` it | +//! | macOS `fieldcraft-cli` | `fieldcraft-cli-V-macos-universal.zip` | the binary | +//! | Windows folder | `fieldcraft-V-windows-x64.zip` | each file (a running `.exe` can be renamed, not overwritten) | +//! | Linux AppImage | `fieldcraft-V-linux-x86_64.AppImage` | the file | +//! | Linux tar.gz folder | `fieldcraft-V-linux-x86_64.tar.gz` | the folder | +//! | `.deb` / `.rpm` (`/usr/bin`) | `fieldcraft_V_amd64.deb` / `fieldcraft-V.x86_64.rpm` | none: the system's package manager installs it | +//! +//! Each swap moves the installed item into the staging folder and the new one into its place, +//! both renames within one folder's file system, and undoes the moves made when one fails. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use crate::{Error, Result, Version}; + +/// The macOS app's bundle identifier (packaging/macos/Info.plist.in). +pub const BUNDLE_ID: &str = "io.github.jyviko.fieldcraft"; +/// The prefix of a staging folder, beside what it replaces (`.fieldcraft-update-0.5.9`). +pub const STAGE_PREFIX: &str = ".fieldcraft-update-"; + +/// How this copy of FieldCraft is installed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Install { + /// The macOS app bundle (`…/FieldCraft.app`). + MacApp { bundle: PathBuf }, + /// The macOS command-line tool from its own zip. + MacCli { exe: PathBuf }, + /// The folder the Windows zip was unpacked into (`fieldcraft.exe`, `fieldcraft-cli.exe`, …). + Windows { dir: PathBuf }, + /// A Linux AppImage file. + AppImage { file: PathBuf }, + /// The folder the Linux tar.gz was unpacked into (`bin/`, `share/`). + TarGz { root: PathBuf }, + /// Installed by the Debian package (`/usr/bin`). + Deb, + /// Installed by the RPM package (`/usr/bin`). + Rpm, + /// Not a release package (a development build, an unknown layout): it checks, but does not + /// install. The reason, in words. + Unmanaged(String), +} + +/// What [`detect`] looks at. +pub struct Facts<'a> { + /// The running executable (`std::env::current_exe`). + pub exe: &'a Path, + /// `APPIMAGE`: the AppImage file the AppImage runtime started this from. + pub appimage: Option<&'a Path>, + /// `std::env::consts::OS`. + pub os: &'a str, + /// Whether a path exists. + pub exists: &'a dyn Fn(&Path) -> bool, +} + +/// How this process's program is installed. +pub fn here() -> Install { + let exe = std::env::current_exe().ok().map(|p| std::fs::canonicalize(&p).unwrap_or(p)); + let appimage = std::env::var_os("APPIMAGE").filter(|a| !a.is_empty()).map(PathBuf::from); + let Some(exe) = exe else { return Install::Unmanaged("the running program's path is unknown".into()) }; + detect(&Facts { exe: &exe, appimage: appimage.as_deref(), os: std::env::consts::OS, exists: &|p: &Path| p.exists() }) +} + +/// How the program at `facts.exe` is installed. +pub fn detect(f: &Facts) -> Install { + let dir = f.exe.parent().unwrap_or(Path::new("")); + let name = f.exe.file_name().and_then(|n| n.to_str()).unwrap_or_default(); + let unmanaged = || Install::Unmanaged(format!("{} is not from a FieldCraft release package (a development build?)", f.exe.display())); + match f.os { + "macos" => { + // …/FieldCraft.app/Contents/MacOS/FieldCraft + let bundle = dir.parent().and_then(Path::parent); + if let Some(bundle) = bundle.filter(|b| b.extension().is_some_and(|e| e == "app") && dir.ends_with("Contents/MacOS")) + && (f.exists)(&bundle.join("Contents/Info.plist")) + { + return Install::MacApp { bundle: bundle.to_path_buf() }; + } + if name == "fieldcraft-cli" && !is_build_dir(dir) { + return Install::MacCli { exe: f.exe.to_path_buf() }; + } + unmanaged() + } + "windows" => { + let packaged = ["fieldcraft.exe", "fieldcraft-cli.exe", "THIRD-PARTY-LICENSES.txt"].iter().all(|n| (f.exists)(&dir.join(n))); + if packaged { Install::Windows { dir: dir.to_path_buf() } } else { unmanaged() } + } + "linux" => { + if let Some(file) = f.appimage { + return Install::AppImage { file: file.to_path_buf() }; + } + if dir == Path::new("/usr/bin") { + return if (f.exists)(Path::new("/etc/debian_version")) { Install::Deb } else { Install::Rpm }; + } + // /bin/fieldcraft with /share/doc/fieldcraft (packaging/linux/package.sh). + match dir.parent() { + Some(root) if dir.ends_with("bin") && (f.exists)(&root.join("share/doc/fieldcraft/THIRD-PARTY-LICENSES.txt")) => { + Install::TarGz { root: root.to_path_buf() } + } + _ => unmanaged(), + } + } + other => Install::Unmanaged(format!("there are no FieldCraft release packages for {other}")), + } +} + +/// A cargo build folder (`target/debug`, `target/release`). +fn is_build_dir(dir: &Path) -> bool { + dir.components().any(|c| c.as_os_str().to_str().is_some_and(|s| s == "target" || s.starts_with("target-"))) +} + +impl Install { + /// The release asset that updates this install to `v` (release builds are macOS universal, + /// Linux x86_64 and Windows x64; `arch` is `std::env::consts::ARCH`). `None` when it cannot + /// update itself. + pub fn asset_name(&self, v: &Version, arch: &str) -> Option { + let x64 = arch == "x86_64"; + Some(match self { + Install::MacApp { .. } => format!("fieldcraft-{v}-macos-universal.dmg"), + Install::MacCli { .. } => format!("fieldcraft-cli-{v}-macos-universal.zip"), + // 64-bit Arm Windows runs the x64 build. + Install::Windows { .. } => format!("fieldcraft-{v}-windows-x64.zip"), + Install::AppImage { .. } if x64 => format!("fieldcraft-{v}-linux-x86_64.AppImage"), + Install::TarGz { .. } if x64 => format!("fieldcraft-{v}-linux-x86_64.tar.gz"), + Install::Deb if x64 => format!("fieldcraft_{v}_amd64.deb"), + Install::Rpm if x64 => format!("fieldcraft-{v}.x86_64.rpm"), + _ => return None, + }) + } + + /// The file or folder an update replaces (`None` for system packages and unmanaged builds). + pub fn target(&self) -> Option<&Path> { + match self { + Install::MacApp { bundle } => Some(bundle), + Install::MacCli { exe } => Some(exe), + Install::Windows { dir } => Some(dir), + Install::AppImage { file } => Some(file), + Install::TarGz { root } => Some(root), + Install::Deb | Install::Rpm | Install::Unmanaged(_) => None, + } + } + + /// Where a download for it is staged: beside what it replaces (one file system, so the swap + /// is a rename); `downloads` for a system package. + pub fn stage_dir(&self, v: &Version, downloads: &Path) -> Option { + match self { + Install::Deb | Install::Rpm => Some(downloads.to_path_buf()), + other => other.target().and_then(Path::parent).map(|p| p.join(format!("{STAGE_PREFIX}{v}"))), + } + } + + /// Why it cannot install updates itself, when it cannot. + pub fn unsupported(&self) -> Option { + match self { + Install::Unmanaged(why) => Some(why.clone()), + _ => None, + } + } + + /// In words, for messages. + pub fn describe(&self) -> String { + match self { + Install::MacApp { bundle } => format!("the app at {}", bundle.display()), + Install::MacCli { exe } => format!("fieldcraft-cli at {}", exe.display()), + Install::Windows { dir } => format!("the folder {}", dir.display()), + Install::AppImage { file } => format!("the AppImage {}", file.display()), + Install::TarGz { root } => format!("the folder {}", root.display()), + Install::Deb => "the Debian package".into(), + Install::Rpm => "the RPM package".into(), + Install::Unmanaged(why) => why.clone(), + } + } +} + +/// A verified release, unpacked and ready to swap in ([`crate::stage::stage`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Staged { + pub install: Install, + pub version: Version, + /// The staging folder (removed once the swap is done); for a system package, the folder the + /// package was downloaded to. + pub dir: PathBuf, + /// The new app bundle, AppImage, binary, unpacked folder or package file. + pub payload: PathBuf, + /// For a Windows folder: the names of the files the new folder holds. + pub entries: Vec, +} + +/// One item replaced: `target` (when it exists) moves to `backup`, then `new` moves to `target`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Replace { + pub target: PathBuf, + pub new: PathBuf, + pub backup: PathBuf, +} + +/// The swap: replace each item in order, start the new version (when asked), then remove the +/// staging folder. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Plan { + pub replace: Vec, + /// The program (and its arguments) that starts the new version. + pub launch: Option<(PathBuf, Vec)>, + pub cleanup: PathBuf, +} + +/// How a staged release is finished. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Finish { + /// Swapped in by FieldCraft after the window closes (or at once by the CLI). + Swap(Plan), + /// A system package: the downloaded file, opened with the system's installer or installed with + /// the command given. + Package { file: PathBuf, command: String }, +} + +/// The plan that finishes `s`; `relaunch` starts the new app after the swap (the window's +/// update, not the CLI's). +pub fn plan(s: &Staged, relaunch: bool) -> Result { + let one = |target: &Path, backup_name: &str, launch: Option<(PathBuf, Vec)>| { + Finish::Swap(Plan { + replace: vec![Replace { target: target.to_path_buf(), new: s.payload.clone(), backup: s.dir.join(backup_name) }], + launch: if relaunch { launch } else { None }, + cleanup: s.dir.clone(), + }) + }; + Ok(match &s.install { + Install::MacApp { bundle } => { + one(bundle, "previous.app", Some((PathBuf::from("/usr/bin/open"), vec!["-n".into(), bundle.as_os_str().to_owned()]))) + } + Install::MacCli { exe } => one(exe, "previous-fieldcraft-cli", None), + Install::AppImage { file } => one(file, "previous.AppImage", Some((file.clone(), Vec::new()))), + Install::TarGz { root } => one(root, "previous", Some((root.join("bin/fieldcraft"), Vec::new()))), + Install::Windows { dir } => { + if s.entries.is_empty() { + return Err(Error::Install("the staged folder is empty".into())); + } + let replace = + s.entries.iter().map(|n| Replace { target: dir.join(n), new: s.payload.join(n), backup: s.dir.join("previous").join(n) }).collect(); + Finish::Swap(Plan { replace, launch: relaunch.then(|| (dir.join("fieldcraft.exe"), Vec::new())), cleanup: s.dir.clone() }) + } + Install::Deb => Finish::Package { file: s.payload.clone(), command: format!("sudo apt install {}", shell_quote(&s.payload)) }, + Install::Rpm => Finish::Package { file: s.payload.clone(), command: format!("sudo dnf install {}", shell_quote(&s.payload)) }, + Install::Unmanaged(why) => return Err(Error::NotSupported(why.clone())), + }) +} + +/// `path` as one POSIX shell word. +fn shell_quote(path: &Path) -> String { + let s = path.to_string_lossy(); + if s.bytes().all(|b| b.is_ascii_alphanumeric() || b"/._-+".contains(&b)) { s.into_owned() } else { format!("'{}'", s.replace('\'', r"'\''")) } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn detect_with(os: &str, exe: &str, appimage: Option<&str>, present: &[&str]) -> Install { + let present: Vec = present.iter().map(PathBuf::from).collect(); + let exists = |p: &Path| present.iter().any(|q| q == p); + detect(&Facts { exe: Path::new(exe), appimage: appimage.map(Path::new), os, exists: &exists }) + } + + #[test] + fn installs_are_recognised_per_platform() { + let mac = detect_with( + "macos", + "/Applications/FieldCraft.app/Contents/MacOS/FieldCraft", + None, + &["/Applications/FieldCraft.app/Contents/Info.plist"], + ); + assert_eq!(mac, Install::MacApp { bundle: "/Applications/FieldCraft.app".into() }); + assert_eq!(detect_with("macos", "/usr/local/bin/fieldcraft-cli", None, &[]), Install::MacCli { exe: "/usr/local/bin/fieldcraft-cli".into() }); + assert!(matches!(detect_with("macos", "/src/fieldcraft/target/release/fieldcraft-cli", None, &[]), Install::Unmanaged(_))); + assert!(matches!(detect_with("macos", "/src/fieldcraft/target/release/fieldcraft", None, &[]), Install::Unmanaged(_))); + + let win = ["C:/FieldCraft/fieldcraft.exe", "C:/FieldCraft/fieldcraft-cli.exe", "C:/FieldCraft/THIRD-PARTY-LICENSES.txt"]; + assert_eq!(detect_with("windows", "C:/FieldCraft/fieldcraft.exe", None, &win), Install::Windows { dir: "C:/FieldCraft".into() }); + assert!(matches!(detect_with("windows", "C:/src/target/release/fieldcraft.exe", None, &[]), Install::Unmanaged(_))); + + assert_eq!( + detect_with("linux", "/tmp/.mount_x/usr/bin/fieldcraft", Some("/home/a/FieldCraft.AppImage"), &[]), + Install::AppImage { file: "/home/a/FieldCraft.AppImage".into() } + ); + assert_eq!(detect_with("linux", "/usr/bin/fieldcraft", None, &["/etc/debian_version"]), Install::Deb); + assert_eq!(detect_with("linux", "/usr/bin/fieldcraft", None, &[]), Install::Rpm); + assert_eq!( + detect_with("linux", "/opt/fc/bin/fieldcraft", None, &["/opt/fc/share/doc/fieldcraft/THIRD-PARTY-LICENSES.txt"]), + Install::TarGz { root: "/opt/fc".into() } + ); + assert!(matches!(detect_with("linux", "/src/target/debug/fieldcraft", None, &[]), Install::Unmanaged(_))); + assert!(matches!(detect_with("freebsd", "/usr/local/bin/fieldcraft", None, &[]), Install::Unmanaged(_))); + } + + #[test] + fn each_install_picks_its_release_asset() { + let v = Version::parse("0.5.9").unwrap(); + let cases = [ + (Install::MacApp { bundle: "/A/FieldCraft.app".into() }, "aarch64", Some("fieldcraft-0.5.9-macos-universal.dmg")), + (Install::MacCli { exe: "/b/fieldcraft-cli".into() }, "x86_64", Some("fieldcraft-cli-0.5.9-macos-universal.zip")), + (Install::Windows { dir: "C:/F".into() }, "x86_64", Some("fieldcraft-0.5.9-windows-x64.zip")), + (Install::Windows { dir: "C:/F".into() }, "aarch64", Some("fieldcraft-0.5.9-windows-x64.zip")), + (Install::AppImage { file: "/a.AppImage".into() }, "x86_64", Some("fieldcraft-0.5.9-linux-x86_64.AppImage")), + (Install::AppImage { file: "/a.AppImage".into() }, "aarch64", None), + (Install::TarGz { root: "/opt/fc".into() }, "x86_64", Some("fieldcraft-0.5.9-linux-x86_64.tar.gz")), + (Install::Deb, "x86_64", Some("fieldcraft_0.5.9_amd64.deb")), + (Install::Rpm, "x86_64", Some("fieldcraft-0.5.9.x86_64.rpm")), + (Install::Unmanaged("dev".into()), "x86_64", None), + ]; + for (install, arch, want) in cases { + assert_eq!(install.asset_name(&v, arch).as_deref(), want, "{install:?} {arch}"); + } + // The names release.yml checks for (its "Check the set" step). + let workflow = include_str!("../../../.github/workflows/release.yml"); + for name in [ + "fieldcraft-$VERSION-macos-universal.dmg", + "fieldcraft-cli-$VERSION-macos-universal.zip", + "fieldcraft-$VERSION-linux-x86_64.tar.gz", + "fieldcraft-$VERSION-linux-x86_64.AppImage", + "fieldcraft_${VERSION}_amd64.deb", + "fieldcraft-$VERSION.x86_64.rpm", + "fieldcraft-$VERSION-windows-x64.zip", + ] { + assert!(workflow.contains(name), "{name}"); + } + } + + fn staged(install: Install, payload: &str, entries: &[&str]) -> Staged { + let v = Version::parse("0.5.9").unwrap(); + let dir = install.stage_dir(&v, Path::new("/dl")).unwrap(); + Staged { install, version: v, dir, payload: payload.into(), entries: entries.iter().map(|e| e.to_string()).collect() } + } + + #[test] + fn each_install_has_its_swap_plan() { + let mac = + staged(Install::MacApp { bundle: "/Applications/FieldCraft.app".into() }, "/Applications/.fieldcraft-update-0.5.9/FieldCraft.app", &[]); + let Finish::Swap(p) = plan(&mac, true).unwrap() else { panic!() }; + assert_eq!( + p.replace, + [Replace { + target: "/Applications/FieldCraft.app".into(), + new: "/Applications/.fieldcraft-update-0.5.9/FieldCraft.app".into(), + backup: "/Applications/.fieldcraft-update-0.5.9/previous.app".into() + }] + ); + assert_eq!(p.launch, Some(("/usr/bin/open".into(), vec!["-n".into(), "/Applications/FieldCraft.app".into()]))); + assert_eq!(p.cleanup, PathBuf::from("/Applications/.fieldcraft-update-0.5.9")); + let Finish::Swap(p) = plan(&mac, false).unwrap() else { panic!() }; + assert_eq!(p.launch, None); + + let win = staged( + Install::Windows { dir: "C:/Tools/FieldCraft".into() }, + "C:/Tools/.fieldcraft-update-0.5.9/new/fieldcraft-0.5.9-windows-x64", + &["fieldcraft.exe", "fieldcraft-cli.exe", "README.md"], + ); + let Finish::Swap(p) = plan(&win, true).unwrap() else { panic!() }; + assert_eq!(p.replace.len(), 3); + assert_eq!(p.replace[0].target, PathBuf::from("C:/Tools/FieldCraft/fieldcraft.exe")); + assert_eq!(p.replace[0].backup, PathBuf::from("C:/Tools/.fieldcraft-update-0.5.9/previous/fieldcraft.exe")); + assert_eq!(p.launch.map(|l| l.0), Some(PathBuf::from("C:/Tools/FieldCraft/fieldcraft.exe"))); + assert!(plan(&staged(Install::Windows { dir: "C:/F".into() }, "C:/x", &[]), true).is_err()); + + let app_image = + staged(Install::AppImage { file: "/home/a/Apps/FieldCraft.AppImage".into() }, "/home/a/Apps/.fieldcraft-update-0.5.9/new.AppImage", &[]); + let Finish::Swap(p) = plan(&app_image, true).unwrap() else { panic!() }; + assert_eq!( + (p.replace[0].target.clone(), p.launch.map(|l| l.0)), + ("/home/a/Apps/FieldCraft.AppImage".into(), Some("/home/a/Apps/FieldCraft.AppImage".into())) + ); + + let tar = staged(Install::TarGz { root: "/opt/fieldcraft".into() }, "/opt/.fieldcraft-update-0.5.9/new/fieldcraft-0.5.9-linux-x86_64", &[]); + let Finish::Swap(p) = plan(&tar, true).unwrap() else { panic!() }; + assert_eq!( + (p.replace[0].backup.clone(), p.launch.map(|l| l.0)), + ("/opt/.fieldcraft-update-0.5.9/previous".into(), Some("/opt/fieldcraft/bin/fieldcraft".into())) + ); + + let cli = + staged(Install::MacCli { exe: "/usr/local/bin/fieldcraft-cli".into() }, "/usr/local/bin/.fieldcraft-update-0.5.9/fieldcraft-cli", &[]); + let Finish::Swap(p) = plan(&cli, true).unwrap() else { panic!() }; + assert_eq!(p.launch, None, "the CLI is not relaunched"); + + // System packages are installed by the system's package manager, never replaced. + let deb = staged(Install::Deb, "/home/a/.local/share/fieldcraft/updates/fieldcraft_0.5.9_amd64.deb", &[]); + assert_eq!( + plan(&deb, true).unwrap(), + Finish::Package { + file: "/home/a/.local/share/fieldcraft/updates/fieldcraft_0.5.9_amd64.deb".into(), + command: "sudo apt install /home/a/.local/share/fieldcraft/updates/fieldcraft_0.5.9_amd64.deb".into() + } + ); + let rpm = staged(Install::Rpm, "/home/a b/fieldcraft-0.5.9.x86_64.rpm", &[]); + let Finish::Package { command, .. } = plan(&rpm, true).unwrap() else { panic!() }; + assert_eq!(command, "sudo dnf install '/home/a b/fieldcraft-0.5.9.x86_64.rpm'"); + let dev = Staged { + install: Install::Unmanaged("dev".into()), + version: Version::parse("1.0.0").unwrap(), + dir: "/x".into(), + payload: "/x/y".into(), + entries: vec![], + }; + assert!(plan(&dev, true).is_err()); + } +} diff --git a/crates/update/src/lib.rs b/crates/update/src/lib.rs new file mode 100644 index 0000000..d6b2e0b --- /dev/null +++ b/crates/update/src/lib.rs @@ -0,0 +1,109 @@ +//! FieldCraft's updates (docs/updates.md). +//! +//! - **Check** ([`net::check`]): ask the GitHub Releases API for the latest published release and +//! compare its [`Version`] with the running one. The desktop app checks at start-up at most +//! once a day ([`due`]) when the user leaves automatic checks on, and from Help › Check for +//! Updates; `app.update.check` and `fieldcraft-cli update --check` run the same check. +//! - **Verify** ([`verify`]): a release's `SHA256SUMS.txt` must carry a signature by the release +//! key ([`PUBLIC_KEY`], Ed25519 in minisign's format), and the downloaded asset must have the +//! SHA-256 that list gives it. Anything else is refused; an unsigned release can only be +//! downloaded by hand from its page. +//! - **Install** ([`install`], [`stage`]): how this copy is installed decides the asset and the +//! swap. The verified release is unpacked beside the install and swapped in by renames +//! (after the window closes, for the app), or, for `.deb`/`.rpm`, handed to the system's +//! package manager. +//! +//! Downloading and unpacking are native only: the browser build has the pure parts (versions, +//! releases, plans), and the web app updates through its service worker. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +pub mod install; +mod release; +pub mod verify; +mod version; + +#[cfg(all(any(test, feature = "mock"), not(target_arch = "wasm32")))] +pub mod mock; +#[cfg(not(target_arch = "wasm32"))] +pub mod net; +#[cfg(not(target_arch = "wasm32"))] +pub mod stage; + +pub use install::{Finish, Install, Plan, Staged}; +pub use release::{Asset, Check, Release}; +pub use version::Version; + +/// The repository releases come from. +pub const REPO: &str = "jyviko/fieldcraft"; +/// The GitHub API. +pub const DEFAULT_HOST: &str = "https://api.github.com"; +/// Replaces [`DEFAULT_HOST`] (and allows plain `http`), for tests against a local server only. +pub const HOST_ENV: &str = "FIELDCRAFT_UPDATE_HOST"; +/// The public half of the release signing key (minisign format, key id FEF410EFEF5BAB21; +/// docs/releasing.md says how it is kept and rotated). +pub const PUBLIC_KEY: &str = "RWQhq1vv7xD0/uAOt8fqERP1hP+1UCM9U9QHTDYTYytWKiuedTkT7Lgr"; +/// The checksum list every release carries. +pub const SUMS: &str = "SHA256SUMS.txt"; +/// Its signature by the release key. +pub const SIGNATURE: &str = "SHA256SUMS.txt.minisig"; +/// The least time between two automatic checks (seconds). +pub const CHECK_INTERVAL: u64 = 24 * 60 * 60; + +/// An update failed. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// The release server could not be reached or answered with an error. + #[error("{0}")] + Network(String), + /// The release description is not usable. + #[error("{0}")] + Release(String), + /// A signature or checksum does not match: nothing is installed. + #[error("{0}")] + Verify(String), + /// Unpacking or swapping in failed. + #[error("{0}")] + Install(String), + /// This copy cannot install updates itself (a development build, an unknown layout). + #[error("this copy of FieldCraft cannot update itself: {0}")] + NotSupported(String), +} + +pub type Result = std::result::Result; + +/// The running version. +pub fn current() -> Version { + Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version { major: 0, minor: 0, patch: 0, pre: Vec::new() }) +} + +/// Whether an automatic check is due: checks are on and the last one (seconds since the Unix +/// epoch, 0 for never) was at least [`CHECK_INTERVAL`] ago, or the clock went back past it. +pub fn due(enabled: bool, last_checked: u64, now: u64) -> bool { + enabled && (last_checked == 0 || now < last_checked || now - last_checked >= CHECK_INTERVAL) +} + +/// The release server: [`HOST_ENV`] when set, else GitHub. +pub fn host() -> String { + std::env::var(HOST_ENV) + .ok() + .map(|h| h.trim().trim_end_matches('/').to_string()) + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| DEFAULT_HOST.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn automatic_checks_are_daily_and_only_when_on() { + let day = CHECK_INTERVAL; + assert!(due(true, 0, 1_000)); + assert!(!due(true, 1_000, 1_000 + day - 1)); + assert!(due(true, 1_000, 1_000 + day)); + assert!(due(true, 5_000, 1_000), "the clock went back"); + assert!(!due(false, 0, 10 * day), "off means no check"); + assert_eq!(current().to_string(), env!("CARGO_PKG_VERSION")); + } +} diff --git a/crates/update/src/mock.rs b/crates/update/src/mock.rs new file mode 100644 index 0000000..e7dd76e --- /dev/null +++ b/crates/update/src/mock.rs @@ -0,0 +1,83 @@ +//! A local HTTP server that plays the GitHub Releases API and serves release files, for tests +//! (`FIELDCRAFT_UPDATE_HOST=http://127.0.0.1:PORT`). It answers each request from a table of +//! paths and counts the requests, so a test can also check that nothing was asked. + +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; + +type Routes = Arc)>>>; + +/// The server; it runs until the process ends. +pub struct Server { + port: u16, + routes: Routes, + requests: Arc, +} + +impl Server { + /// Start on a free local port. `None` when no port can be bound. + pub fn start() -> Option { + let listener = TcpListener::bind("127.0.0.1:0").ok()?; + let port = listener.local_addr().ok()?.port(); + let routes: Routes = Arc::default(); + let requests = Arc::new(AtomicUsize::new(0)); + let (r, n) = (routes.clone(), requests.clone()); + std::thread::Builder::new() + .name("fieldcraft-update mock".into()) + .spawn(move || { + for stream in listener.incoming().flatten() { + n.fetch_add(1, Ordering::SeqCst); + let _ = answer(stream, &r); + } + }) + .ok()?; + Some(Server { port, routes, requests }) + } + + /// Its base URL, for `FIELDCRAFT_UPDATE_HOST`. + pub fn url(&self) -> String { + format!("http://127.0.0.1:{}", self.port) + } + + /// Answer `path` with `status` and `body`. + pub fn route(&self, path: &str, status: u16, body: Vec) { + self.routes.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_string(), (status, body)); + } + + /// Answer the API's `releases/latest` (`which` = `latest`) or `releases/tags/` with + /// `release`. + pub fn release(&self, which: &str, release: &serde_json::Value) { + let path = if which == "latest" { + format!("/repos/{}/releases/latest", crate::REPO) + } else { + format!("/repos/{}/releases/tags/{which}", crate::REPO) + }; + self.route(&path, 200, release.to_string().into_bytes()); + } + + /// How many requests it has had. + pub fn requests(&self) -> usize { + self.requests.load(Ordering::SeqCst) + } +} + +fn answer(stream: TcpStream, routes: &Routes) -> std::io::Result<()> { + let mut reader = BufReader::new(stream.try_clone()?); + let mut line = String::new(); + reader.read_line(&mut line)?; + // The headers, up to the blank line. + let mut header = String::new(); + while reader.read_line(&mut header)? > 2 { + header.clear(); + } + let path = line.split_whitespace().nth(1).unwrap_or("/").to_string(); + let (status, body) = + routes.lock().unwrap_or_else(PoisonError::into_inner).get(&path).cloned().unwrap_or((404, b"{\"message\":\"Not Found\"}".to_vec())); + let mut stream = stream; + write!(stream, "HTTP/1.1 {status} X\r\nContent-Length: {}\r\nContent-Type: application/octet-stream\r\nConnection: close\r\n\r\n", body.len())?; + stream.write_all(&body)?; + stream.flush() +} diff --git a/crates/update/src/net.rs b/crates/update/src/net.rs new file mode 100644 index 0000000..fdddfc2 --- /dev/null +++ b/crates/update/src/net.rs @@ -0,0 +1,190 @@ +//! Talking to the release server: the GitHub Releases API for the release description, and its +//! download links for the files. HTTPS only (checked against the operating system's +//! certificates), except against a local test server named by [`crate::HOST_ENV`]. + +use std::io::{Read, Write}; +use std::path::Path; +use std::time::Duration; + +use sha2::{Digest, Sha256}; + +use crate::{Check, Error, REPO, Release, Result, Version}; + +/// The longest a release description may be (bytes). +const MAX_JSON: u64 = 4 * 1024 * 1024; +/// The largest asset downloaded (bytes; FieldCraft's are well under 200 MB). +pub const MAX_ASSET: u64 = 2 * 1024 * 1024 * 1024; +/// How long connecting and the release description may take. +const TIMEOUT: Duration = Duration::from_secs(15); +/// How long a whole download may take. +const DOWNLOAD_TIME: Duration = Duration::from_secs(30 * 60); + +/// Which release to ask for. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Which { + /// The latest published, non-draft, non-pre-release release. + Latest, + /// The release of this version (to reinstall it). + Version(Version), +} + +/// A client for one release server. +pub struct Client { + host: String, + agent: ureq::Agent, + downloads: ureq::Agent, +} + +impl Client { + /// A client for `host` (`https://api.github.com`, or a local test server). + pub fn new(host: &str) -> Client { + // rustls's process-wide crypto (ring); another part of the app may have installed it. + let _ = rustls::crypto::ring::default_provider().install_default(); + let tls = || ureq::tls::TlsConfig::builder().root_certs(ureq::tls::RootCerts::PlatformVerifier).build(); + let agent = ureq::Agent::config_builder().timeout_global(Some(TIMEOUT)).tls_config(tls()).build().into(); + let downloads = ureq::Agent::config_builder() + .timeout_connect(Some(TIMEOUT)) + .timeout_recv_response(Some(TIMEOUT)) + .timeout_recv_body(Some(DOWNLOAD_TIME)) + .tls_config(tls()) + .build() + .into(); + Client { host: host.trim_end_matches('/').to_string(), agent, downloads } + } + + /// The client for [`crate::host`]. + pub fn from_env() -> Client { + Client::new(&crate::host()) + } + + /// Whether `url` may be fetched: HTTPS, or anything when testing against a local server. + fn allowed(&self, url: &str) -> Result<()> { + if url.starts_with("https://") || self.host != crate::DEFAULT_HOST { + Ok(()) + } else { + Err(Error::Network(format!("refusing a download that is not HTTPS: {}", url.chars().take(120).collect::()))) + } + } + + /// The release `which` describes. + pub fn release(&self, which: &Which) -> Result { + let path = match which { + Which::Latest => format!("/repos/{REPO}/releases/latest"), + Which::Version(v) => format!("/repos/{REPO}/releases/tags/v{v}"), + }; + let url = format!("{}{path}", self.host); + let reply = self + .agent + .get(&url) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", &format!("FieldCraft/{}", crate::current())) + .config() + .http_status_as_error(false) + .build() + .call() + .map_err(|e| Error::Network(format!("could not reach the release server: {e}")))?; + let status = reply.status().as_u16(); + let body = reply + .into_body() + .with_config() + .limit(MAX_JSON) + .read_to_string() + .map_err(|e| Error::Network(format!("the release server's reply: {e}")))?; + match status { + 200 => {} + 404 => { + return Err(Error::Release(match which { + Which::Latest => "no FieldCraft release is published yet".into(), + Which::Version(v) => format!("there is no published release of FieldCraft {v}"), + })); + } + 403 | 429 => return Err(Error::Network("the release server is limiting requests; try again later".into())), + other => return Err(Error::Network(format!("the release server answered {other}"))), + } + let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| Error::Release(format!("the release description is not JSON: {e}")))?; + Release::from_github(&json) + } + + /// Compare the latest release with the running version. + pub fn check(&self) -> Result { + Ok(Check { current: crate::current(), latest: self.release(&Which::Latest)? }) + } + + /// A small file (the checksum list, its signature), at most `max` bytes. + pub fn small(&self, url: &str, max: usize) -> Result> { + self.allowed(url)?; + let reply = self.agent.get(url).header("User-Agent", "FieldCraft").call().map_err(|e| Error::Network(format!("download failed: {e}")))?; + reply.into_body().with_config().limit(max as u64).read_to_vec().map_err(|e| Error::Network(format!("download failed: {e}"))) + } + + /// Download `url` to `to`, at most `max` bytes; its SHA-256 (hex). `progress` hears the bytes + /// so far and the total (0 when not known). + pub fn download(&self, url: &str, to: &Path, max: u64, progress: &dyn Fn(u64, u64)) -> Result { + self.allowed(url)?; + let reply = self.downloads.get(url).header("User-Agent", "FieldCraft").call().map_err(|e| Error::Network(format!("download failed: {e}")))?; + let total = reply.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.parse::().ok()).unwrap_or(0); + if total > max { + return Err(Error::Network(format!("the download is {total} bytes, more than the {max} accepted"))); + } + let mut body = reply.into_body(); + let mut reader = body.with_config().limit(max).reader(); + let mut file = std::fs::File::create(to).map_err(|e| Error::Install(format!("{}: {e}", to.display())))?; + let mut hash = Sha256::new(); + let mut buf = vec![0u8; 256 * 1024]; + let mut done = 0u64; + loop { + let n = reader.read(&mut buf).map_err(|e| Error::Network(format!("download failed: {e}")))?; + let Some(chunk) = buf.get(..n).filter(|c| !c.is_empty()) else { break }; + hash.update(chunk); + file.write_all(chunk).map_err(|e| Error::Install(format!("{}: {e}", to.display())))?; + done += n as u64; + progress(done, total); + } + file.sync_all().map_err(|e| Error::Install(format!("{}: {e}", to.display())))?; + Ok(crate::verify::hex(&hash.finalize())) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::mock::Server; + + #[test] + fn the_latest_release_is_asked_for_and_compared() { + let server = Server::start().unwrap(); + server.release("latest", &json!({"tag_name": "v99.0.0", "html_url": "https://example.invalid/r", "assets": []})); + let check = Client::new(&server.url()).check().unwrap(); + assert!(check.newer()); + assert_eq!(check.latest.version.to_string(), "99.0.0"); + assert_eq!(server.requests(), 1); + + // Errors are messages, never panics. + let missing = Client::new(&server.url()).release(&Which::Version(Version::parse("1.2.3").unwrap())).unwrap_err(); + assert!(missing.to_string().contains("no published release of FieldCraft 1.2.3"), "{missing}"); + server.route("/repos/jyviko/fieldcraft/releases/latest", 200, b"not json".to_vec()); + assert!(matches!(Client::new(&server.url()).check(), Err(Error::Release(_)))); + server.route("/repos/jyviko/fieldcraft/releases/latest", 403, b"{}".to_vec()); + assert!(matches!(Client::new(&server.url()).check(), Err(Error::Network(_)))); + assert!(matches!(Client::new("http://127.0.0.1:1").check(), Err(Error::Network(_)))); + } + + #[test] + fn downloads_are_hashed_and_capped() { + let server = Server::start().unwrap(); + server.route("/f.bin", 200, b"0123456789".to_vec()); + let dir = std::env::temp_dir().join(format!("fieldcraft-update-net-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let to = dir.join("f.bin"); + let c = Client::new(&server.url()); + let sha = c.download(&format!("{}/f.bin", server.url()), &to, 100, &|_, _| {}).unwrap(); + assert_eq!(sha, crate::verify::sha256_hex(b"0123456789")); + assert!(c.download(&format!("{}/f.bin", server.url()), &to, 5, &|_, _| {}).is_err()); + assert!(c.small(&format!("{}/f.bin", server.url()), 5).is_err()); + // Against GitHub, only HTTPS. + assert!(Client::new(crate::DEFAULT_HOST).small("http://example.invalid/x", 5).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/update/src/release.rs b/crates/update/src/release.rs new file mode 100644 index 0000000..459724a --- /dev/null +++ b/crates/update/src/release.rs @@ -0,0 +1,175 @@ +//! A published release, as the GitHub Releases API describes it +//! (`GET /repos/{owner}/{repo}/releases/latest`, `…/releases/tags/{tag}`). The reply is hostile +//! input: every string and list is capped, and a draft is refused. + +use serde_json::{Value, json}; + +use crate::{Error, Result, SIGNATURE, SUMS, Version}; + +/// The most assets a release may list (FieldCraft's have nine). +const MAX_ASSETS: usize = 100; +/// The longest asset name, URL or tag kept. +const MAX_NAME: usize = 512; +/// The most of the release notes kept (bytes). +const MAX_NOTES: usize = 64 * 1024; + +/// A file of a release. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Asset { + pub name: String, + /// Where it downloads from (`browser_download_url`). + pub url: String, + /// Its size in bytes, as the release lists it (0 when not given). + pub size: u64, +} + +/// A published (non-draft) release. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Release { + pub version: Version, + /// Its tag (`v0.5.8`). + pub tag: String, + /// Its page on GitHub: the release notes and every download. + pub url: String, + /// The release notes (Markdown, capped). + pub notes: String, + /// When it was published (ISO 8601), as GitHub gives it. + pub published: String, + pub prerelease: bool, + pub assets: Vec, +} + +/// A string field of `v`, capped at `max` bytes (cut at a character boundary). +fn text(v: &Value, key: &str, max: usize) -> String { + let s = v.get(key).and_then(Value::as_str).unwrap_or_default(); + let mut end = s.len().min(max); + while !s.is_char_boundary(end) { + end -= 1; + } + s.get(..end).unwrap_or_default().to_string() +} + +impl Release { + /// The release a GitHub Releases API reply describes. + pub fn from_github(v: &Value) -> Result { + if v.get("draft").and_then(Value::as_bool) == Some(true) { + return Err(Error::Release("the release is a draft".into())); + } + let tag = text(v, "tag_name", MAX_NAME); + let version = + Version::parse(&tag).ok_or_else(|| Error::Release(format!("tag `{}` is not a version", tag.chars().take(64).collect::())))?; + let list = v.get("assets").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + if list.len() > MAX_ASSETS { + return Err(Error::Release(format!("the release lists {} assets (at most {MAX_ASSETS})", list.len()))); + } + let assets = list + .iter() + .map(|a| Asset { + name: text(a, "name", MAX_NAME), + url: text(a, "browser_download_url", MAX_NAME), + size: a.get("size").and_then(Value::as_u64).unwrap_or(0), + }) + .filter(|a| !a.name.is_empty() && !a.url.is_empty()) + .collect(); + Ok(Release { + version, + tag, + url: text(v, "html_url", MAX_NAME), + notes: text(v, "body", MAX_NOTES), + published: text(v, "published_at", 64), + prerelease: v.get("prerelease").and_then(Value::as_bool).unwrap_or(false), + assets, + }) + } + + /// The asset named `name`. + pub fn asset(&self, name: &str) -> Option<&Asset> { + self.assets.iter().find(|a| a.name == name) + } + + /// Whether the release carries the signed checksum list an install needs + /// (`SHA256SUMS.txt` and `SHA256SUMS.txt.minisig`). + pub fn signed(&self) -> bool { + self.asset(SUMS).is_some() && self.asset(SIGNATURE).is_some() + } +} + +/// What a check found: the running version and the latest release. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Check { + pub current: Version, + pub latest: Release, +} + +impl Check { + /// The latest release is newer than the running version. + pub fn newer(&self) -> bool { + self.latest.version > self.current + } + + /// As `app.update.check` and `fieldcraft-cli update --check` report it; `asset` is the + /// file this install would download (`None` when it cannot install itself). + pub fn to_json(&self, asset: Option<&str>) -> Value { + json!({ + "current": self.current.to_string(), + "latest": self.latest.version.to_string(), + "newer": self.newer(), + "url": self.latest.url, + "published": self.latest.published, + "signed": self.latest.signed(), + "asset": asset, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + pub(crate) fn github(tag: &str) -> Value { + json!({ + "tag_name": tag, + "html_url": format!("https://github.com/jyviko/fieldcraft/releases/tag/{tag}"), + "body": "## What's new\n- updates", + "draft": false, + "prerelease": false, + "published_at": "2026-10-15T04:06:51Z", + "assets": [ + {"name": "SHA256SUMS.txt", "browser_download_url": "https://example.invalid/SHA256SUMS.txt", "size": 700}, + {"name": "SHA256SUMS.txt.minisig", "browser_download_url": "https://example.invalid/SHA256SUMS.txt.minisig", "size": 300}, + {"name": "", "browser_download_url": "https://example.invalid/x"}, + ], + }) + } + + #[test] + fn a_release_reads_from_the_api_reply() { + let r = Release::from_github(&github("v0.5.9")).unwrap(); + assert_eq!((r.version.to_string().as_str(), r.tag.as_str(), r.assets.len()), ("0.5.9", "v0.5.9", 2)); + assert!(r.signed() && r.notes.starts_with("## What's new")); + let check = Check { current: Version::parse("0.5.8").unwrap(), latest: r.clone() }; + assert!(check.newer()); + assert_eq!(check.to_json(Some("a.dmg"))["latest"], "0.5.9"); + assert!(!Check { current: Version::parse("0.5.9").unwrap(), latest: r.clone() }.newer()); + assert!(!Check { current: Version::parse("0.6.0-rc.1").unwrap(), latest: r }.newer()); + + let mut unsigned = github("v0.5.9"); + unsigned["assets"] = json!([]); + assert!(!Release::from_github(&unsigned).unwrap().signed()); + let mut draft = github("v0.5.9"); + draft["draft"] = json!(true); + assert!(Release::from_github(&draft).is_err()); + for hostile in [ + json!(null), + json!([]), + json!({"tag_name": 7}), + json!({"tag_name": "latest"}), + json!({"tag_name": "v1.0.0", "assets": vec![json!({}); 1000]}), + ] { + assert!(Release::from_github(&hostile).is_err(), "{hostile}"); + } + let mut long = github("v1.0.0"); + long["body"] = json!("é".repeat(MAX_NOTES)); + assert!(Release::from_github(&long).unwrap().notes.len() <= MAX_NOTES); + } +} diff --git a/crates/update/src/stage.rs b/crates/update/src/stage.rs new file mode 100644 index 0000000..3ea1c4e --- /dev/null +++ b/crates/update/src/stage.rs @@ -0,0 +1,559 @@ +//! Downloading, verifying and unpacking a release beside the install ([`stage`]), and swapping it +//! in ([`apply`]). +//! +//! Nothing is unpacked before the checksum list's signature and the asset's checksum are +//! checked; a staged macOS app must also pass `codesign --verify --deep --strict`, carry the +//! FieldCraft bundle id, and pass Gatekeeper (`spctl`) whenever the installed app does. Archives +//! are unpacked with every path kept inside the staging folder and their sizes capped. + +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::install::{Install, Plan, STAGE_PREFIX, Staged}; +use crate::net::{Client, MAX_ASSET}; +use crate::verify::{self, MAX_SIGNATURE, MAX_SUMS}; +use crate::{Error, Release, Result, SIGNATURE, SUMS}; + +/// The most files an archive may hold. +const MAX_ENTRIES: usize = 20_000; +/// The most an archive may unpack to (bytes). +const MAX_UNPACKED: u64 = 4 * 1024 * 1024 * 1024; + +/// What staging needs besides the release and the install. +pub struct Options<'a> { + /// The release key's public half (minisign base64): [`crate::PUBLIC_KEY`], or a test key. + pub key: &'a str, + /// Where a system package (`.deb`, `.rpm`) is downloaded to. + pub downloads: &'a Path, + /// `std::env::consts::ARCH`. + pub arch: &'a str, + /// Hears the bytes downloaded so far and the total (0 when not known). + pub progress: &'a dyn Fn(u64, u64), +} + +fn io(what: &Path) -> impl Fn(std::io::Error) -> Error + '_ { + move |e| Error::Install(format!("{}: {e}", what.display())) +} + +/// Download `release`'s asset for `install`, verify it and unpack it into a staging folder. +pub fn stage(client: &Client, release: &Release, install: &Install, o: &Options) -> Result { + if let Some(why) = install.unsupported() { + return Err(Error::NotSupported(why)); + } + let v = &release.version; + let name = + install.asset_name(v, o.arch).ok_or_else(|| Error::NotSupported(format!("there is no release build for this computer ({})", o.arch)))?; + let (Some(sums), Some(sig)) = (release.asset(SUMS), release.asset(SIGNATURE)) else { + return Err(Error::Verify(format!("FieldCraft {v} is not signed, so it cannot be installed from here; download it from {}", release.url))); + }; + let asset = release.asset(&name).ok_or_else(|| Error::Release(format!("FieldCraft {v} has no {name}")))?; + // The list and its signature first: nothing else is downloaded unless they check out. + let sums_bytes = client.small(&sums.url, MAX_SUMS)?; + let sig = String::from_utf8(client.small(&sig.url, MAX_SIGNATURE)?).map_err(|_| Error::Verify("SHA256SUMS.txt.minisig is not text".into()))?; + verify::signature(&sums_bytes, &sig, o.key)?; + let sums = verify::parse_sums(std::str::from_utf8(&sums_bytes).map_err(|_| Error::Verify("SHA256SUMS.txt is not text".into()))?)?; + if !sums.contains_key(&name) { + return Err(Error::Verify(format!("SHA256SUMS.txt does not list {name}"))); + } + + let dir = install.stage_dir(v, o.downloads).ok_or_else(|| Error::Install(format!("no folder to stage {name} in")))?; + let package = matches!(install, Install::Deb | Install::Rpm); + if !package && dir.exists() { + fs::remove_dir_all(&dir).map_err(io(&dir))?; + } + fs::create_dir_all(&dir).map_err(|e| { + Error::Install(format!( + "cannot write to {}: {e}. Install FieldCraft {v} from {} instead, or give your account write access there", + dir.parent().unwrap_or(&dir).display(), + release.url + )) + })?; + let staged = (|| { + let file = dir.join(&name); + let sha = client.download(&asset.url, &file, MAX_ASSET, o.progress)?; + if let Err(e) = verify::checksum(&sums, &name, &sha) { + let _ = fs::remove_file(&file); + return Err(e); + } + let (payload, entries) = unpack(install, &file, &dir)?; + Ok(Staged { install: install.clone(), version: v.clone(), dir: dir.clone(), payload, entries }) + })(); + if staged.is_err() && !package { + let _ = fs::remove_dir_all(&dir); + } + staged +} + +/// Unpack the verified download `file` in `dir`: the new app, file or folder, and (for a +/// Windows folder) the names it holds. +fn unpack(install: &Install, file: &Path, dir: &Path) -> Result<(PathBuf, Vec)> { + match install { + Install::MacApp { bundle } => { + let app = mount_and_copy_app(file, dir)?; + let _ = fs::remove_file(file); + check_mac_app(&app, bundle)?; + Ok((app, Vec::new())) + } + Install::MacCli { .. } => { + let out = dir.join("unpacked"); + unzip(file, &out)?; + let _ = fs::remove_file(file); + let exe = find(&out, &|p| p.file_name().is_some_and(|n| n == "fieldcraft-cli") && p.is_file()) + .ok_or_else(|| Error::Install("the download has no fieldcraft-cli".into()))?; + executable(&exe)?; + codesign_verify(&exe)?; + Ok((exe, Vec::new())) + } + Install::Windows { .. } => { + let out = dir.join("new"); + unzip(file, &out)?; + let _ = fs::remove_file(file); + let folder = + find(&out, &|p| p.join("fieldcraft.exe").is_file()).ok_or_else(|| Error::Install("the download has no fieldcraft.exe".into()))?; + let mut entries: Vec = + fs::read_dir(&folder).map_err(io(&folder))?.flatten().filter_map(|e| e.file_name().to_str().map(str::to_string)).collect(); + entries.sort(); + Ok((folder, entries)) + } + Install::TarGz { .. } => { + let out = dir.join("new"); + untar_gz(file, &out)?; + let _ = fs::remove_file(file); + let root = + find(&out, &|p| p.join("bin/fieldcraft").is_file()).ok_or_else(|| Error::Install("the download has no bin/fieldcraft".into()))?; + Ok((root, Vec::new())) + } + Install::AppImage { .. } => { + executable(file)?; + Ok((file.to_path_buf(), Vec::new())) + } + Install::Deb | Install::Rpm => Ok((file.to_path_buf(), Vec::new())), + Install::Unmanaged(why) => Err(Error::NotSupported(why.clone())), + } +} + +/// `dir` itself or the first folder below it (two levels) that `is` accepts. +fn find(dir: &Path, is: &dyn Fn(&Path) -> bool) -> Option { + if is(dir) { + return Some(dir.to_path_buf()); + } + let mut children: Vec = fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).collect(); + children.sort(); + children.iter().find(|c| is(c)).cloned().or_else(|| children.iter().filter(|c| c.is_dir()).find_map(|c| find_shallow(c, is))) +} + +fn find_shallow(dir: &Path, is: &dyn Fn(&Path) -> bool) -> Option { + let mut children: Vec = fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).collect(); + children.sort(); + children.into_iter().find(|c| is(c)) +} + +/// Mark `file` executable (Unix). +fn executable(file: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(file, fs::Permissions::from_mode(0o755)).map_err(io(file))?; + } + #[cfg(not(unix))] + let _ = file; + Ok(()) +} + +/// Unpack zip `file` into `out`, every path kept inside it. +fn unzip(file: &Path, out: &Path) -> Result<()> { + let bad = |e: zip::result::ZipError| Error::Install(format!("{}: {e}", file.display())); + let mut archive = zip::ZipArchive::new(fs::File::open(file).map_err(io(file))?).map_err(bad)?; + if archive.len() > MAX_ENTRIES { + return Err(Error::Install(format!("{} holds more than {MAX_ENTRIES} files", file.display()))); + } + let mut total = 0u64; + for i in 0..archive.len() { + let mut entry = archive.by_index(i).map_err(bad)?; + let rel = entry.enclosed_name().ok_or_else(|| Error::Install(format!("{} holds a path outside its folder", file.display())))?; + let path = out.join(rel); + if entry.is_dir() { + fs::create_dir_all(&path).map_err(io(&path))?; + continue; + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(io(parent))?; + } + let mut f = fs::File::create(&path).map_err(io(&path))?; + let left = MAX_UNPACKED.saturating_sub(total); + let n = std::io::copy(&mut (&mut entry).take(left + 1), &mut f).map_err(io(&path))?; + total += n; + if total > MAX_UNPACKED { + return Err(Error::Install(format!("{} unpacks to more than {MAX_UNPACKED} bytes", file.display()))); + } + #[cfg(unix)] + if let Some(mode) = entry.unix_mode() { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(mode & 0o777)).map_err(io(&path))?; + } + } + Ok(()) +} + +/// Unpack tar.gz `file` into `out`, every path kept inside it. +fn untar_gz(file: &Path, out: &Path) -> Result<()> { + fs::create_dir_all(out).map_err(io(out))?; + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(fs::File::open(file).map_err(io(file))?)); + archive.set_preserve_permissions(true); + let mut total = 0u64; + for (i, entry) in archive.entries().map_err(io(file))?.enumerate() { + let mut entry = entry.map_err(io(file))?; + total = total.saturating_add(entry.header().size().unwrap_or(0)); + if i >= MAX_ENTRIES || total > MAX_UNPACKED { + return Err(Error::Install(format!("{} holds more than {MAX_ENTRIES} files or {MAX_UNPACKED} bytes", file.display()))); + } + if !entry.unpack_in(out).map_err(io(file))? { + return Err(Error::Install(format!("{} holds a path outside its folder", file.display()))); + } + } + Ok(()) +} + +/// Run `program` with `args`; its failure, with what it printed, as an error. +fn run(program: &str, args: &[&std::ffi::OsStr]) -> Result { + let out = Command::new(program).args(args).output().map_err(|e| Error::Install(format!("{program}: {e}")))?; + if out.status.success() { Ok(out) } else { Err(Error::Install(format!("{program} failed: {}", String::from_utf8_lossy(&out.stderr).trim()))) } +} + +/// Copy `FieldCraft.app` out of disk image `dmg` into `dir`. +fn mount_and_copy_app(dmg: &Path, dir: &Path) -> Result { + if !cfg!(target_os = "macos") { + return Err(Error::NotSupported("a disk image is opened on macOS only".into())); + } + let mnt = dir.join("mnt"); + fs::create_dir_all(&mnt).map_err(io(&mnt))?; + run( + "hdiutil", + &[ + "attach".as_ref(), + "-nobrowse".as_ref(), + "-readonly".as_ref(), + "-noautoopen".as_ref(), + "-mountpoint".as_ref(), + mnt.as_os_str(), + dmg.as_os_str(), + ], + )?; + let app = dir.join("FieldCraft.app"); + let copied = run("ditto", &[mnt.join("FieldCraft.app").as_os_str(), app.as_os_str()]); + if run("hdiutil", &["detach".as_ref(), mnt.as_os_str()]).is_err() { + let _ = run("hdiutil", &["detach".as_ref(), "-force".as_ref(), mnt.as_os_str()]); + } + copied?; + let _ = fs::remove_dir(&mnt); + Ok(app) +} + +/// `codesign --verify --strict` (macOS; nothing elsewhere). +fn codesign_verify(path: &Path) -> Result<()> { + if cfg!(target_os = "macos") { + run("codesign", &["--verify".as_ref(), "--deep".as_ref(), "--strict".as_ref(), path.as_os_str()]) + .map_err(|e| Error::Verify(format!("the downloaded program's code signature is not valid: {e}")))?; + } + Ok(()) +} + +/// A staged macOS app must be validly signed, be FieldCraft (its bundle id), and pass Gatekeeper +/// whenever the installed app does (a notarized install is never replaced by one that is not). +fn check_mac_app(app: &Path, installed: &Path) -> Result<()> { + codesign_verify(app)?; + let info = run("codesign", &["-d".as_ref(), "--verbose=2".as_ref(), app.as_os_str()])?; + let text = String::from_utf8_lossy(&info.stderr); + if !text.lines().any(|l| l.trim() == format!("Identifier={}", crate::install::BUNDLE_ID)) { + return Err(Error::Verify(format!("the downloaded app is not {}", crate::install::BUNDLE_ID))); + } + let gatekeeper = |p: &Path| run("spctl", &["--assess".as_ref(), "--type".as_ref(), "execute".as_ref(), p.as_os_str()]); + if gatekeeper(installed).is_ok() { + gatekeeper(app).map_err(|e| Error::Verify(format!("Gatekeeper does not accept the downloaded app: {e}")))?; + } + Ok(()) +} + +/// Swap the staged release in: each item moves to its backup and the new one into its place; a +/// failure puts back what was moved. Then the new version starts (when the plan says so) and +/// the staging folder is removed (on Windows the old, still running program stays in it until +/// [`clean_stale`] at the next start). +pub fn apply(plan: &Plan) -> Result<()> { + let mut done: Vec<(&crate::install::Replace, bool)> = Vec::new(); + let undo = |done: &[(&crate::install::Replace, bool)]| { + for (r, had) in done.iter().rev() { + let _ = fs::rename(&r.target, &r.new); + if *had { + let _ = fs::rename(&r.backup, &r.target); + } + } + }; + for r in &plan.replace { + let had = fs::symlink_metadata(&r.target).is_ok(); + if had { + if let Some(parent) = r.backup.parent() { + fs::create_dir_all(parent).map_err(io(parent))?; + } + if let Err(e) = fs::rename(&r.target, &r.backup) { + undo(&done); + return Err(Error::Install(format!("{}: {e}", r.target.display()))); + } + } + if let Err(e) = fs::rename(&r.new, &r.target) { + if had { + let _ = fs::rename(&r.backup, &r.target); + } + undo(&done); + return Err(Error::Install(format!("{}: {e}", r.target.display()))); + } + done.push((r, had)); + } + if let Some((program, args)) = &plan.launch + && let Err(e) = Command::new(program).args(args).spawn() + { + log::warn!("update: installed, but {} did not start: {e}", program.display()); + } + let _ = fs::remove_dir_all(&plan.cleanup); + Ok(()) +} + +/// Remove staging folders an earlier update left beside `install` (on Windows, the old program +/// could not be deleted while it ran). +pub fn clean_stale(install: &Install) { + let Some(parent) = install.target().and_then(Path::parent) else { return }; + let Ok(entries) = fs::read_dir(parent) else { return }; + for e in entries.flatten() { + if e.file_name().to_str().is_some_and(|n| n.starts_with(STAGE_PREFIX)) && e.path().is_dir() { + let _ = fs::remove_dir_all(e.path()); + } + } +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use serde_json::json; + + use super::*; + use crate::install::{Finish, plan}; + use crate::mock::Server; + use crate::verify::tests::{keypair, sign}; + + struct Temp(PathBuf); + impl Temp { + fn new(name: &str) -> Temp { + let d = std::env::temp_dir().join(format!("fieldcraft-update-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + Temp(d) + } + } + impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + /// A tar.gz of the Linux layout, `fieldcraft-9.0.0-linux-x86_64/bin/fieldcraft` saying `text`. + fn linux_tar(text: &str) -> Vec { + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + { + let mut t = tar::Builder::new(&mut gz); + for (path, body) in [ + ("fieldcraft-9.0.0-linux-x86_64/bin/fieldcraft", text), + ("fieldcraft-9.0.0-linux-x86_64/share/doc/fieldcraft/THIRD-PARTY-LICENSES.txt", "licences"), + ] { + let mut h = tar::Header::new_gnu(); + h.set_size(body.len() as u64); + h.set_mode(0o755); + h.set_cksum(); + t.append_data(&mut h, path, body.as_bytes()).unwrap(); + } + t.finish().unwrap(); + } + gz.finish().unwrap() + } + + /// A zip of the Windows layout. + fn windows_zip(text: &str) -> Vec { + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for name in ["fieldcraft.exe", "fieldcraft-cli.exe", "THIRD-PARTY-LICENSES.txt"] { + z.start_file(format!("fieldcraft-9.0.0-windows-x64/{name}"), zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(text.as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() + } + + /// Publish release 9.0.0 on `server` with `assets`, its checksum list (with `tamper` changing + /// it after hashing) signed by `sk`. + fn publish(server: &Server, sk: Option<&minisign::SecretKey>, assets: &[(&str, Vec)], tamper: bool) -> Release { + let mut sums: String = assets.iter().map(|(n, b)| format!("{} {n}\n", verify::sha256_hex(b))).collect(); + if tamper { + sums = assets.iter().map(|(n, _)| format!("{} {n}\n", verify::sha256_hex(b"something else"))).collect(); + } + let mut list = Vec::new(); + for (name, body) in assets { + server.route(&format!("/dl/{name}"), 200, body.clone()); + list.push(json!({"name": name, "browser_download_url": format!("{}/dl/{name}", server.url()), "size": body.len()})); + } + server.route("/dl/SHA256SUMS.txt", 200, sums.clone().into_bytes()); + list.push(json!({"name": "SHA256SUMS.txt", "browser_download_url": format!("{}/dl/SHA256SUMS.txt", server.url())})); + if let Some(sk) = sk { + server.route("/dl/SHA256SUMS.txt.minisig", 200, sign(sk, sums.as_bytes()).into_bytes()); + list.push(json!({"name": "SHA256SUMS.txt.minisig", "browser_download_url": format!("{}/dl/SHA256SUMS.txt.minisig", server.url())})); + } + let r = json!({"tag_name": "v9.0.0", "html_url": "https://example.invalid/v9.0.0", "assets": list}); + server.release("latest", &r); + Release::from_github(&r).unwrap() + } + + fn options<'a>(key: &'a str, downloads: &'a Path) -> Options<'a> { + Options { key, downloads, arch: "x86_64", progress: &|_, _| {} } + } + + /// A tar.gz install is staged beside itself, verified, and swapped in by renames; the + /// staging folder goes. + #[test] + fn a_verified_folder_release_is_staged_and_swapped_in() { + let t = Temp::new("tar"); + let root = t.0.join("fieldcraft"); + fs::create_dir_all(root.join("bin")).unwrap(); + fs::write(root.join("bin/fieldcraft"), "old").unwrap(); + let install = Install::TarGz { root: root.clone() }; + let server = Server::start().unwrap(); + let (sk, public) = keypair(); + let release = publish(&server, Some(&sk), &[("fieldcraft-9.0.0-linux-x86_64.tar.gz", linux_tar("new"))], false); + + let staged = stage(&Client::new(&server.url()), &release, &install, &options(&public, &t.0)).unwrap(); + assert!(staged.dir.starts_with(&t.0) && staged.dir.file_name().unwrap() == ".fieldcraft-update-9.0.0"); + assert_eq!(fs::read_to_string(staged.payload.join("bin/fieldcraft")).unwrap(), "new"); + assert_eq!(fs::read_to_string(root.join("bin/fieldcraft")).unwrap(), "old", "nothing changes before the swap"); + let Finish::Swap(p) = plan(&staged, false).unwrap() else { panic!() }; + apply(&p).unwrap(); + assert_eq!(fs::read_to_string(root.join("bin/fieldcraft")).unwrap(), "new"); + assert!(!staged.dir.exists()); + } + + /// The Windows folder is swapped file by file (a running .exe can be renamed); the old files + /// wait in the staging folder, which the next start removes. + #[test] + fn a_windows_folder_is_swapped_file_by_file() { + let t = Temp::new("win"); + let dir = t.0.join("FieldCraft"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("fieldcraft.exe"), "old").unwrap(); + fs::write(dir.join("settings-the-user-added.txt"), "mine").unwrap(); + let install = Install::Windows { dir: dir.clone() }; + let server = Server::start().unwrap(); + let (sk, public) = keypair(); + let release = publish(&server, Some(&sk), &[("fieldcraft-9.0.0-windows-x64.zip", windows_zip("new"))], false); + let staged = stage(&Client::new(&server.url()), &release, &install, &options(&public, &t.0)).unwrap(); + assert_eq!(staged.entries, ["THIRD-PARTY-LICENSES.txt", "fieldcraft-cli.exe", "fieldcraft.exe"]); + let Finish::Swap(p) = plan(&staged, false).unwrap() else { panic!() }; + apply(&p).unwrap(); + assert_eq!(fs::read_to_string(dir.join("fieldcraft.exe")).unwrap(), "new"); + assert_eq!(fs::read_to_string(dir.join("settings-the-user-added.txt")).unwrap(), "mine"); + fs::create_dir_all(t.0.join(".fieldcraft-update-8.0.0/previous")).unwrap(); + clean_stale(&install); + assert!(!t.0.join(".fieldcraft-update-8.0.0").exists()); + } + + /// A failed swap puts back what it moved. + #[test] + fn a_failed_swap_is_undone() { + let t = Temp::new("undo"); + let (a, b) = (t.0.join("a"), t.0.join("b")); + fs::write(&a, "old a").unwrap(); + fs::write(&b, "old b").unwrap(); + fs::write(t.0.join("new-a"), "new a").unwrap(); + let p = Plan { + replace: vec![ + crate::install::Replace { target: a.clone(), new: t.0.join("new-a"), backup: t.0.join("stage/a") }, + crate::install::Replace { target: b.clone(), new: t.0.join("missing"), backup: t.0.join("stage/b") }, + ], + launch: None, + cleanup: t.0.join("stage"), + }; + assert!(apply(&p).is_err()); + assert_eq!((fs::read_to_string(&a).unwrap(), fs::read_to_string(&b).unwrap()), ("old a".into(), "old b".into())); + } + + /// A checksum list that does not match the download, one signed by another key, or none at + /// all: nothing is installed, and with a bad signature the asset is not even downloaded. + #[test] + fn unverified_releases_are_refused() { + let t = Temp::new("refuse"); + let root = t.0.join("fieldcraft"); + fs::create_dir_all(root.join("bin")).unwrap(); + let install = Install::TarGz { root: root.clone() }; + let asset = [("fieldcraft-9.0.0-linux-x86_64.tar.gz", linux_tar("new"))]; + let (sk, public) = keypair(); + + let server = Server::start().unwrap(); + let release = publish(&server, Some(&sk), &asset, true); + let e = stage(&Client::new(&server.url()), &release, &install, &options(&public, &t.0)).unwrap_err(); + assert!(matches!(&e, Error::Verify(m) if m.contains("does not match its checksum")), "{e}"); + assert!(!t.0.join(".fieldcraft-update-9.0.0").exists(), "the staging folder goes"); + + let (other, _) = keypair(); + let server = Server::start().unwrap(); + let release = publish(&server, Some(&other), &asset, false); + let e = stage(&Client::new(&server.url()), &release, &install, &options(&public, &t.0)).unwrap_err(); + assert!(matches!(&e, Error::Verify(m) if m.contains("not signed by the FieldCraft release key")), "{e}"); + assert_eq!(server.requests(), 2, "the list and its signature, not the asset"); + + let server = Server::start().unwrap(); + let release = publish(&server, None, &asset, false); + let e = stage(&Client::new(&server.url()), &release, &install, &options(&public, &t.0)).unwrap_err(); + assert!(matches!(&e, Error::Verify(m) if m.contains("not signed")), "{e}"); + assert_eq!(server.requests(), 0); + + // A development build checks but does not install. + let e = stage(&Client::new(&server.url()), &release, &Install::Unmanaged("a development build".into()), &options(&public, &t.0)).unwrap_err(); + assert!(matches!(e, Error::NotSupported(_))); + } + + /// An AppImage is replaced as one file; a .deb is downloaded for the system's installer. + #[test] + fn app_images_and_packages() { + let t = Temp::new("files"); + let file = t.0.join("FieldCraft.AppImage"); + fs::write(&file, "old").unwrap(); + let server = Server::start().unwrap(); + let (sk, public) = keypair(); + let release = publish( + &server, + Some(&sk), + &[("fieldcraft-9.0.0-linux-x86_64.AppImage", b"new".to_vec()), ("fieldcraft_9.0.0_amd64.deb", b"deb".to_vec())], + false, + ); + let c = Client::new(&server.url()); + let staged = stage(&c, &release, &Install::AppImage { file: file.clone() }, &options(&public, &t.0)).unwrap(); + let Finish::Swap(p) = plan(&staged, false).unwrap() else { panic!() }; + apply(&p).unwrap(); + assert_eq!(fs::read_to_string(&file).unwrap(), "new"); + + let downloads = t.0.join("downloads"); + let staged = stage(&c, &release, &Install::Deb, &options(&public, &downloads)).unwrap(); + let Finish::Package { file, command } = plan(&staged, true).unwrap() else { panic!() }; + assert_eq!(fs::read(&file).unwrap(), b"deb"); + assert!(command.starts_with("sudo apt install ") && command.ends_with("fieldcraft_9.0.0_amd64.deb"), "{command}"); + } + + /// Paths that leave the staging folder are refused. + #[test] + fn archives_cannot_write_outside_their_folder() { + let t = Temp::new("escape"); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + z.start_file("../escaped.txt", zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(b"x").unwrap(); + let bytes = z.finish().unwrap().into_inner(); + let file = t.0.join("bad.zip"); + fs::write(&file, bytes).unwrap(); + assert!(unzip(&file, &t.0.join("out")).is_err()); + assert!(!t.0.join("escaped.txt").exists()); + } +} diff --git a/crates/update/src/verify.rs b/crates/update/src/verify.rs new file mode 100644 index 0000000..1d9035c --- /dev/null +++ b/crates/update/src/verify.rs @@ -0,0 +1,136 @@ +//! What makes a download trustworthy: the release's `SHA256SUMS.txt` is signed with the release +//! key (an Ed25519 key in minisign's format, docs/releasing.md) whose public half is built into +//! the app ([`crate::PUBLIC_KEY`]), and every file installed has the SHA-256 that list gives it. +//! A list that is not signed by that key, or a file that does not match it, is refused. + +use std::collections::BTreeMap; + +use sha2::{Digest, Sha256}; + +use crate::{Error, Result}; + +/// The longest checksum list accepted (bytes; FieldCraft's has nine lines). +pub const MAX_SUMS: usize = 64 * 1024; +/// The longest signature file accepted (bytes; a minisign signature is about 300). +pub const MAX_SIGNATURE: usize = 4 * 1024; + +/// Check that `sums` is signed by `public_key` (minisign's base64 public key) with `signature` +/// (a `.minisig` file). Only prehashed signatures (minisign's default since 0.8) are accepted. +pub fn signature(sums: &[u8], signature: &str, public_key: &str) -> Result<()> { + let key = + minisign_verify::PublicKey::from_base64(public_key).map_err(|e| Error::Verify(format!("the built-in release key is not usable: {e}")))?; + let sig = minisign_verify::Signature::decode(signature).map_err(|e| Error::Verify(format!("SHA256SUMS.txt.minisig is not a signature: {e}")))?; + key.verify(sums, &sig, false).map_err(|e| Error::Verify(format!("SHA256SUMS.txt is not signed by the FieldCraft release key ({e})"))) +} + +/// The file names and SHA-256 digests (lower-case hex) of a `sha256sum` list +/// (`<64 hex> ` per line; a `*` before the name marks binary mode). +pub fn parse_sums(text: &str) -> Result> { + if text.len() > MAX_SUMS { + return Err(Error::Verify("SHA256SUMS.txt is too large".into())); + } + let mut sums = BTreeMap::new(); + for line in text.lines().map(str::trim_end).filter(|l| !l.is_empty()) { + let bad = || Error::Verify(format!("SHA256SUMS.txt: `{}` is not a checksum line", line.chars().take(80).collect::())); + let (hash, name) = line.split_once(' ').ok_or_else(bad)?; + let name = name.trim_start_matches(' ').trim_start_matches('*'); + if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) || name.is_empty() { + return Err(bad()); + } + if sums.insert(name.to_string(), hash.to_ascii_lowercase()).is_some() { + return Err(Error::Verify(format!("SHA256SUMS.txt lists `{name}` twice"))); + } + } + Ok(sums) +} + +/// The SHA-256 of `bytes`, as lower-case hex. +pub fn sha256_hex(bytes: &[u8]) -> String { + hex(&Sha256::digest(bytes)) +} + +/// Bytes as lower-case hex. +pub fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Check that file `name`, whose SHA-256 is `actual`, is the one the list names. +pub fn checksum(sums: &BTreeMap, name: &str, actual: &str) -> Result<()> { + let expected = sums.get(name).ok_or_else(|| Error::Verify(format!("SHA256SUMS.txt does not list {name}")))?; + if !expected.eq_ignore_ascii_case(actual) { + return Err(Error::Verify(format!( + "{name} does not match its checksum (expected {expected}, downloaded {actual}): the download is damaged or was changed" + ))); + } + Ok(()) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + /// A fresh key pair (the test's own; never the release key) and its public key in base64. + pub(crate) fn keypair() -> (minisign::SecretKey, String) { + let kp = minisign::KeyPair::generate_unencrypted_keypair().unwrap(); + let public = kp.pk.to_base64(); + (kp.sk, public) + } + + /// `data` signed with `sk`, as a `.minisig` file. + pub(crate) fn sign(sk: &minisign::SecretKey, data: &[u8]) -> String { + minisign::sign(None, sk, std::io::Cursor::new(data), Some("timestamp:0\tfile:SHA256SUMS.txt"), None).unwrap().into_string() + } + + #[test] + fn checksum_lists_parse() { + let a = "a".repeat(64); + let sums = + parse_sums(&format!("{a} fieldcraft-0.5.9-macos-universal.dmg\n{} *fieldcraft-0.5.9-windows-x64.zip\n\n", "B".repeat(64))).unwrap(); + assert_eq!(sums.len(), 2); + assert_eq!(sums["fieldcraft-0.5.9-windows-x64.zip"], "b".repeat(64)); + for bad in + [a.clone(), format!("{} x", "a".repeat(63)), format!("{} x", "g".repeat(64)), format!("{a} x\n{a} x"), "x".repeat(MAX_SUMS + 1)] + { + assert!(parse_sums(&bad).is_err(), "{bad}"); + } + } + + #[test] + fn a_checksum_mismatch_is_refused() { + let sums = parse_sums(&format!("{} app.zip\n", sha256_hex(b"the app"))).unwrap(); + assert!(checksum(&sums, "app.zip", &sha256_hex(b"the app")).is_ok()); + let e = checksum(&sums, "app.zip", &sha256_hex(b"the app, changed")).unwrap_err(); + assert!(e.to_string().contains("does not match its checksum"), "{e}"); + assert!(checksum(&sums, "other.zip", &sha256_hex(b"the app")).is_err()); + } + + #[test] + fn only_a_signature_by_the_key_is_accepted() { + let (sk, public) = keypair(); + let sums = b"abc fieldcraft.zip\n"; + let sig = sign(&sk, sums); + assert!(signature(sums, &sig, &public).is_ok()); + // The list changed after signing. + assert!(signature(b"abd fieldcraft.zip\n", &sig, &public).is_err()); + // Signed by another key. + let (other, _) = keypair(); + assert!(signature(sums, &sign(&other, sums), &public).is_err()); + // No signature, a damaged one, a bad key. + for bad in ["", "untrusted comment: x\n", &sig.replace('A', "B")] { + assert!(signature(sums, bad, &public).is_err(), "{bad}"); + } + assert!(signature(sums, &sig, "RWQ").is_err()); + // The built-in release key is a valid key. + assert!(minisign_verify::PublicKey::from_base64(crate::PUBLIC_KEY).is_ok()); + } + + /// The built-in key accepts what the release workflow's tool (rsign2) signs with the + /// release key: tests/data holds a list signed that way. Rotating the key re-signs it. + #[test] + fn the_release_key_verifies_the_release_tooling() { + let sums = include_bytes!("../tests/data/SHA256SUMS.txt"); + let sig = include_str!("../tests/data/SHA256SUMS.txt.minisig"); + assert_eq!(signature(sums, sig, crate::PUBLIC_KEY), Ok(())); + assert!(signature(b"changed", sig, crate::PUBLIC_KEY).is_err()); + } +} diff --git a/crates/update/src/version.rs b/crates/update/src/version.rs new file mode 100644 index 0000000..0694cd7 --- /dev/null +++ b/crates/update/src/version.rs @@ -0,0 +1,150 @@ +//! Release versions: `MAJOR.MINOR.PATCH` with an optional pre-release suffix (`0.6.0-rc.1`), +//! ordered as Semantic Versioning 2.0.0 orders them (§11): a pre-release sorts before its +//! release, and pre-release identifiers compare numerically when both are numbers. + +use std::cmp::Ordering; +use std::fmt; + +/// The longest version string accepted (a release tag is short; anything longer is hostile). +const MAX_LEN: usize = 64; + +/// A release version. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Version { + pub major: u64, + pub minor: u64, + pub patch: u64, + /// The pre-release identifiers (`rc.1` is `["rc", "1"]`); empty for a release. + pub pre: Vec, +} + +impl Version { + /// Parse `0.5.8`, `v0.5.8` (a release tag) or `0.6.0-rc.1`. Build metadata (`+…`) is ignored. + pub fn parse(text: &str) -> Option { + let text = text.trim(); + if text.is_empty() || text.len() > MAX_LEN { + return None; + } + let text = text.strip_prefix('v').unwrap_or(text); + let text = text.split_once('+').map_or(text, |(v, _)| v); + let (core, pre) = match text.split_once('-') { + Some((core, pre)) => (core, Some(pre)), + None => (text, None), + }; + let mut parts = core.split('.'); + let mut number = || parts.next().filter(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())).and_then(|p| p.parse::().ok()); + let (major, minor, patch) = (number()?, number()?, number()?); + if parts.next().is_some() { + return None; + } + let pre = match pre { + None => Vec::new(), + Some(pre) => { + let ids: Vec = pre.split('.').map(str::to_string).collect(); + if ids.iter().any(|id| id.is_empty() || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')) { + return None; + } + ids + } + }; + Some(Version { major, minor, patch, pre }) + } + + /// Whether this is a pre-release (`-rc.1`). + pub fn is_prerelease(&self) -> bool { + !self.pre.is_empty() + } +} + +impl fmt::Display for Version { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?; + if !self.pre.is_empty() { + write!(f, "-{}", self.pre.join("."))?; + } + Ok(()) + } +} + +/// One pre-release identifier against another (SemVer §11.4): numbers numerically and below +/// words, words by their ASCII text. +fn compare_id(a: &str, b: &str) -> Ordering { + match (a.parse::(), b.parse::()) { + (Ok(x), Ok(y)) => x.cmp(&y), + (Ok(_), Err(_)) => Ordering::Less, + (Err(_), Ok(_)) => Ordering::Greater, + (Err(_), Err(_)) => a.cmp(b), + } +} + +impl Ord for Version { + fn cmp(&self, other: &Self) -> Ordering { + (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch)).then_with(|| { + match (self.pre.is_empty(), other.pre.is_empty()) { + (true, true) => Ordering::Equal, + (true, false) => Ordering::Greater, + (false, true) => Ordering::Less, + (false, false) => self + .pre + .iter() + .zip(&other.pre) + .map(|(a, b)| compare_id(a, b)) + .find(|o| o.is_ne()) + .unwrap_or_else(|| self.pre.len().cmp(&other.pre.len())), + } + }) + } +} + +impl PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(s: &str) -> Version { + Version::parse(s).unwrap() + } + + #[test] + fn versions_parse_and_print() { + assert_eq!(v("0.5.8"), Version { major: 0, minor: 5, patch: 8, pre: vec![] }); + assert_eq!(v("v1.2.3"), v("1.2.3")); + assert_eq!(v(" 1.2.3+build.7 "), v("1.2.3")); + assert_eq!(v("0.6.0-rc.1").pre, ["rc", "1"]); + assert_eq!(v("0.6.0-rc.1").to_string(), "0.6.0-rc.1"); + assert!(v("0.6.0-rc.1").is_prerelease() && !v("0.6.0").is_prerelease()); + for bad in + ["", "1", "1.2", "1.2.3.4", "1..3", "a.b.c", "1.2.-3", "1.2.3-", "1.2.3-rc..1", "1.2.3-r c", "99999999999999999999.0.0", &"1".repeat(100)] + { + assert_eq!(Version::parse(bad), None, "{bad}"); + } + } + + /// The order SemVer §11 gives, the example from the specification included. + #[test] + fn versions_order_as_semver() { + let order = [ + "0.5.8", + "0.5.9", + "0.5.10", + "0.6.0-alpha", + "0.6.0-alpha.1", + "0.6.0-alpha.beta", + "0.6.0-beta", + "0.6.0-beta.2", + "0.6.0-beta.11", + "0.6.0-rc.1", + "0.6.0", + "1.0.0", + ]; + for pair in order.windows(2) { + assert!(v(pair[0]) < v(pair[1]), "{} < {}", pair[0], pair[1]); + } + assert_eq!(v("1.0.0").cmp(&v("v1.0.0+x")), Ordering::Equal); + } +} diff --git a/crates/update/tests/data/SHA256SUMS.txt b/crates/update/tests/data/SHA256SUMS.txt new file mode 100644 index 0000000..ca38dff --- /dev/null +++ b/crates/update/tests/data/SHA256SUMS.txt @@ -0,0 +1 @@ +0000000000000000000000000000000000000000000000000000000000000000 release-key-check.txt diff --git a/crates/update/tests/data/SHA256SUMS.txt.minisig b/crates/update/tests/data/SHA256SUMS.txt.minisig new file mode 100644 index 0000000..816bd1a --- /dev/null +++ b/crates/update/tests/data/SHA256SUMS.txt.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from rsign secret key +RUQhq1vv7xD0/vTwhuuHVKR/5crDtkVI5jWEkeLH59Ig7FnObgf6cd7J/FKalfcOglTq06ljTrepIeWFsMBiZI1of8IBzuNuMwg= +trusted comment: FieldCraft release key check ++tkrXV+5E1czSU9DFZGA0lntiCm1Pz6ngR+i+3bEdVLHFI6CBjNHM506lRmjOHIe4M/7tVVc2e914G9Q6cakDQ== diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 8c0271f..0f5712b 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -39,6 +39,8 @@ pub const TABLE: &[(&str, Class)] = &[ ("model", Class::Layer(0)), // L0, no workspace dependencies: deterministic thread pool helpers for the solvers. ("parallel", Class::Standalone), + // L0, no workspace dependencies: checking for, verifying and installing releases. + ("update", Class::Standalone), // L1: geometry and math kernels. ("geometry", Class::Layer(1)), ("mesh", Class::Layer(1)),