//! User-triggered FIPS upgrade from upstream `main`. //! //! Flow (no auto-update, no background polling — user clicks a button): //! 1. Query GitHub for the latest commit on `main` of jmcorgan/fips. //! 2. Compare with the installed daemon version reported by //! `fipsctl --version`. If identical, report "up to date". //! 3. Fetch the built .deb artefact for that commit + its SHA256. //! 4. SHA256-verify the download. //! 5. `sudo dpkg -i` the .deb, `sudo systemctl restart` the service. //! //! The artefact URL / SHA256 source is not yet fixed — upstream doesn't //! publish stable release assets for `main` builds. This module currently //! implements steps 1–2 (the "is there anything newer?" query) and stubs //! out 3–5 so the RPC/UI can wire through. The apply path returns a //! clear "not yet available" error until the artefact source is decided. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use super::{service, UPSTREAM_REPO}; const GITHUB_API: &str = "https://api.github.com"; const USER_AGENT: &str = "archipelago-fips-updater"; /// Result of `check_update()` — what the dashboard renders. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateCheck { /// Currently installed daemon version (from `fipsctl --version`). pub current: Option, /// Short SHA of the latest commit on upstream `main`. pub latest_commit: String, /// True when the installed version string does not mention the latest SHA. pub update_available: bool, /// Human-readable note for the UI. pub notes: String, } /// Query GitHub for the latest commit on `main` and compare to the /// installed version. Never errors on "no package installed" — that is /// itself a valid state where an update is available (install needed). pub async fn check() -> Result { let current = service::daemon_version().await.ok(); let latest = fetch_latest_main_sha().await?; let short = latest.chars().take(7).collect::(); let update_available = match ¤t { Some(v) => !v.contains(&short), None => true, }; let notes = if update_available { format!( "Upstream main is at {}; installed: {}", short, current.as_deref().unwrap_or("not installed") ) } else { format!("Up to date ({})", short) }; Ok(UpdateCheck { current, latest_commit: short, update_available, notes, }) } /// Apply the update. Stubbed pending a stable artefact source for /// per-commit builds of the `fips` debian package. When this is wired /// up it must: download → SHA256-verify → `sudo dpkg -i` → restart. pub async fn apply() -> Result<()> { anyhow::bail!( "FIPS auto-apply not yet wired — upstream does not publish stable \ per-commit .deb artefacts for main. Upgrade manually for now: \ `git pull && cargo deb && sudo dpkg -i target/debian/fips_*.deb`." ) } async fn fetch_latest_main_sha() -> Result { let url = format!("{}/repos/{}/commits/main", GITHUB_API, UPSTREAM_REPO); let client = reqwest::Client::builder() .user_agent(USER_AGENT) .timeout(std::time::Duration::from_secs(15)) .build() .context("Build HTTP client")?; let resp = client .get(&url) .header("Accept", "application/vnd.github+json") .send() .await .context("GitHub commits API")?; if !resp.status().is_success() { anyhow::bail!("GitHub API returned {}", resp.status()); } let body: serde_json::Value = resp.json().await.context("Parse commits JSON")?; let sha = body .get("sha") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("GitHub commits response missing sha field"))?; Ok(sha.to_string()) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_apply_returns_clear_stub_error() { let err = apply().await.unwrap_err().to_string(); assert!( err.contains("not yet wired"), "apply() should return an explicit not-yet-wired error, got: {}", err ); } #[test] fn test_update_check_serialises() { let uc = UpdateCheck { current: Some("0.2.0-abc1234".to_string()), latest_commit: "def5678".to_string(), update_available: true, notes: "test".to_string(), }; let json = serde_json::to_string(&uc).unwrap(); assert!(json.contains("latest_commit")); assert!(json.contains("update_available")); } }