forked from 1b-agent/fieldcraft-upstream
Update: check, verify and stage releases in a new fieldcraft-update crate (#231)
Versions ordered as SemVer, the GitHub Releases API reply read as hostile input, how this copy is installed (macOS app or CLI, Windows folder, AppImage, tar.gz folder, .deb/.rpm) and which release asset updates it, the checksum list's minisign signature checked against the built-in release key, the asset's SHA-256 checked against the list, archives unpacked inside the staging folder, and the swap planned and applied by renames with undo on failure. The network and unpacking parts are native only; a mock release server drives the tests. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
4c18322a34
commit
8fe31545ea
14 files changed
+1997
No files matched your search
Generated
+143
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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 };
|
||||
}
|
||||
// <root>/bin/fieldcraft with <root>/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<String> {
|
||||
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<PathBuf> {
|
||||
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<String> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// 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<Replace>,
|
||||
/// The program (and its arguments) that starts the new version.
|
||||
pub launch: Option<(PathBuf, Vec<OsString>)>,
|
||||
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<Finish> {
|
||||
let one = |target: &Path, backup_name: &str, launch: Option<(PathBuf, Vec<OsString>)>| {
|
||||
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<PathBuf> = 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());
|
||||
}
|
||||
}
|
||||
@@ -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<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// 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"));
|
||||
}
|
||||
}
|
||||
@@ -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<Mutex<HashMap<String, (u16, Vec<u8>)>>>;
|
||||
|
||||
/// The server; it runs until the process ends.
|
||||
pub struct Server {
|
||||
port: u16,
|
||||
routes: Routes,
|
||||
requests: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Start on a free local port. `None` when no port can be bound.
|
||||
pub fn start() -> Option<Server> {
|
||||
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<u8>) {
|
||||
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/<which>` 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()
|
||||
}
|
||||
@@ -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::<String>())))
|
||||
}
|
||||
}
|
||||
|
||||
/// The release `which` describes.
|
||||
pub fn release(&self, which: &Which) -> Result<Release> {
|
||||
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<Check> {
|
||||
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<Vec<u8>> {
|
||||
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<String> {
|
||||
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::<u64>().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);
|
||||
}
|
||||
}
|
||||
@@ -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<Asset>,
|
||||
}
|
||||
|
||||
/// 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<Release> {
|
||||
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::<String>())))?;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Staged> {
|
||||
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<String>)> {
|
||||
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<String> =
|
||||
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<PathBuf> {
|
||||
if is(dir) {
|
||||
return Some(dir.to_path_buf());
|
||||
}
|
||||
let mut children: Vec<PathBuf> = 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<PathBuf> {
|
||||
let mut children: Vec<PathBuf> = 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<std::process::Output> {
|
||||
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<PathBuf> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8>)], 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());
|
||||
}
|
||||
}
|
||||
@@ -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> <name>` per line; a `*` before the name marks binary mode).
|
||||
pub fn parse_sums(text: &str) -> Result<BTreeMap<String, String>> {
|
||||
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::<String>()));
|
||||
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<String, String>, 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());
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<Version> {
|
||||
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::<u64>().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<String> = 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::<u64>(), b.parse::<u64>()) {
|
||||
(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<Ordering> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
0000000000000000000000000000000000000000000000000000000000000000 release-key-check.txt
|
||||
@@ -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==
|
||||
@@ -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)),
|
||||
|
||||
Reference in new issue
Block a user