diff --git a/Cargo.lock b/Cargo.lock index 0a2c89f..be8f974 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2274,6 +2274,7 @@ dependencies = [ "rand 0.8.6", "reqwest", "rsa", + "semver", "serde", "serde_json", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 7d34a27..e24c881 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ reqwest = { version = "0.13", default-features = false, features = [ "json", "query", ] } +semver = "1" [profile.release] lto = "thin" diff --git a/crates/hephd/Cargo.toml b/crates/hephd/Cargo.toml index 9bb7b9e..fb30b17 100644 --- a/crates/hephd/Cargo.toml +++ b/crates/hephd/Cargo.toml @@ -32,6 +32,7 @@ jsonwebtoken.workspace = true keyring-core.workspace = true reqwest.workspace = true ureq.workspace = true +semver.workspace = true # The OS credential backend that `oauth.rs` registers as the keyring-core # default store — exactly one per platform, not the whole keyring meta-crate. diff --git a/crates/hephd/src/lib.rs b/crates/hephd/src/lib.rs index 09f8714..5d68bad 100644 --- a/crates/hephd/src/lib.rs +++ b/crates/hephd/src/lib.rs @@ -17,6 +17,7 @@ pub mod oauth; pub mod quickadd; pub mod remote; pub mod rpc; +pub mod selfupdate; pub mod server; pub mod sync; diff --git a/crates/hephd/src/selfupdate.rs b/crates/hephd/src/selfupdate.rs new file mode 100644 index 0000000..336b035 --- /dev/null +++ b/crates/hephd/src/selfupdate.rs @@ -0,0 +1,117 @@ +//! Opt-in self-update (cards: `docs/how-to/self-update/`). When enabled, hephd +//! polls the forge for a newer tagged release and rebuilds + restarts onto it. +//! +//! This first slice is the **version check**: parse the running version, fetch +//! the latest release tag from the forge, and decide whether an update exists. +//! The decision logic is pure and unit-tested; the network fetch is isolated so +//! its JSON parsing can be tested against a sample body without a live forge. + +use anyhow::{Context, Result}; + +/// The forge releases feed for this project — the latest tagged release. +/// Uses the SSH-canonical host (`forge.ops.eblu.me`); see the +/// `service-env-forge-access` card for the cargo/forge host caveat. +pub const RELEASES_LATEST_URL: &str = + "https://forge.ops.eblu.me/api/v1/repos/eblume/hephaestus/releases/latest"; + +/// Extract the bare `X.Y.Z` semver from a version string that may carry a build +/// suffix (`heph_core::VERSION` is e.g. `"1.0.3 (aa376b4)"`) or a leading `v` +/// (release tags are `v1.0.4`). +fn parse_version(s: &str) -> Result { + let head = s + .trim() + .trim_start_matches('v') + .split_whitespace() + .next() + .unwrap_or(""); + semver::Version::parse(head).with_context(|| format!("parsing version {s:?}")) +} + +/// Whether `latest_tag` names a strictly newer release than `current` (the +/// running `heph_core::VERSION`). A malformed version on either side is an +/// error — never a silent "no update". +pub fn update_available(current: &str, latest_tag: &str) -> Result { + Ok(parse_version(latest_tag)? > parse_version(current)?) +} + +/// Pull the `tag_name` out of a Forgejo/Gitea `releases/latest` response body. +/// Split out from the HTTP fetch so it can be tested against a sample payload. +pub fn parse_latest_tag(body: &str) -> Result { + #[derive(serde::Deserialize)] + struct Release { + tag_name: String, + } + let rel: Release = + serde_json::from_str(body).context("parsing forge releases/latest response")?; + Ok(rel.tag_name) +} + +/// Fetch the latest release tag from the forge over HTTP (reusing the daemon's +/// shared `reqwest::Client`). Network/HTTP/JSON failures surface as `Err` for +/// the caller to log-and-continue. +pub async fn fetch_latest_tag(http: &reqwest::Client, url: &str) -> Result { + let body = http + .get(url) + .send() + .await + .context("requesting forge releases/latest")? + .error_for_status() + .context("forge releases/latest returned an error status")? + .text() + .await + .context("reading forge releases/latest body")?; + parse_latest_tag(&body) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn update_available_compares_ignoring_build_suffix_and_v_prefix() { + // Running version carries a build-sha suffix; tags carry a `v`. + assert!(update_available("1.0.3 (aa376b4)", "v1.0.4").unwrap()); + assert!(update_available("1.0.3 (aa376b4)", "v2.0.0").unwrap()); + // Same version → no update (a dirty rebuild of the same tag isn't newer). + assert!(!update_available("1.0.3 (aa376b4-dirty)", "v1.0.3").unwrap()); + // Older tag than running → no update. + assert!(!update_available("1.0.3", "v1.0.2").unwrap()); + // Patch/minor/major ordering. + assert!(update_available("1.0.9", "v1.1.0").unwrap()); + assert!(!update_available("1.1.0", "v1.0.9").unwrap()); + } + + #[test] + fn update_available_errors_on_malformed_version() { + assert!(update_available("not-a-version", "v1.0.4").is_err()); + assert!(update_available("1.0.3", "vNope").is_err()); + } + + #[test] + fn parse_latest_tag_reads_tag_name_from_forge_body() { + // A trimmed sample of a Forgejo releases/latest payload. + let body = r#"{ + "id": 42, + "tag_name": "v1.0.4", + "name": "Release v1.0.4", + "draft": false, + "prerelease": false + }"#; + assert_eq!(parse_latest_tag(body).unwrap(), "v1.0.4"); + } + + #[test] + fn parse_latest_tag_errors_on_unexpected_body() { + assert!(parse_latest_tag("{}").is_err()); + assert!(parse_latest_tag("not json").is_err()); + } + + #[test] + fn end_to_end_body_to_decision() { + // Parse a release body, then decide against a fixed running version. + let tag = parse_latest_tag(r#"{"tag_name": "v1.0.4"}"#).unwrap(); + assert!(update_available("1.0.3 (aa376b4)", &tag).unwrap()); + let tag = parse_latest_tag(r#"{"tag_name": "v1.0.3"}"#).unwrap(); + assert!(!update_available("1.0.3 (aa376b4)", &tag).unwrap()); + } +}