//! User-triggered FIPS upgrade from the upstream default branch. //! //! Flow (no auto-update, no background polling — user clicks a button): //! 1. Query GitHub for the upstream repo's default branch, then the //! latest commit on it. (jmcorgan/fips default is `master`, not //! `main` — we resolve it dynamically so a future rename Just Works.) //! 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 per-commit 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 the upstream default branch and /// compare to the installed version. Never errors on "no package installed" /// — that is itself a valid state where an update is available. pub async fn check() -> Result { let current = service::daemon_version().await.ok(); let client = reqwest::Client::builder() .user_agent(USER_AGENT) .timeout(std::time::Duration::from_secs(15)) .build() .context("Build HTTP client")?; let branch = fetch_default_branch(&client).await?; let latest = fetch_head_sha(&client, &branch).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 {} is at {}; installed: {}", branch, short, current.as_deref().unwrap_or("not installed") ) } else { format!("Up to date ({} @ {})", branch, 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_default_branch(client: &reqwest::Client) -> Result { let url = format!("{}/repos/{}", GITHUB_API, UPSTREAM_REPO); let resp = client .get(&url) .header("Accept", "application/vnd.github+json") .send() .await .context("GitHub repo API")?; if !resp.status().is_success() { anyhow::bail!("GitHub repo API returned {}", resp.status()); } let body: serde_json::Value = resp.json().await.context("Parse repo JSON")?; body.get("default_branch") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .ok_or_else(|| anyhow::anyhow!("GitHub repo response missing default_branch")) } async fn fetch_head_sha(client: &reqwest::Client, branch: &str) -> Result { let url = format!("{}/repos/{}/commits/{}", GITHUB_API, UPSTREAM_REPO, branch); 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 commits API returned {} for branch {}", resp.status(), branch ); } let body: serde_json::Value = resp.json().await.context("Parse commits JSON")?; body.get("sha") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .ok_or_else(|| anyhow::anyhow!("GitHub commits response missing sha field")) } #[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")); } }