diff --git a/core/archipelago/src/container/app_catalog.rs b/core/archipelago/src/container/app_catalog.rs index 8fb6803e..f18b3d48 100644 --- a/core/archipelago/src/container/app_catalog.rs +++ b/core/archipelago/src/container/app_catalog.rs @@ -373,9 +373,9 @@ pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result let mut last_err: Option = None; for url in &urls { match fetch_one(&client, url).await { - Ok(catalog) => { + Ok((catalog, body)) => { let count = catalog.apps.len(); - let changed = write_cache(data_dir, &catalog)?; + let changed = write_cache(data_dir, &body)?; if changed { // Invalidate the in-process cache so the next read re-parses. *CACHE.lock().unwrap() = None; @@ -400,7 +400,10 @@ pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable"))) } -async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result { +async fn fetch_one( + client: &reqwest::Client, + url: &str, +) -> anyhow::Result<(AppCatalog, String)> { let resp = client.get(url).send().await?; if !resp.status().is_success() { anyhow::bail!("HTTP {}", resp.status()); @@ -439,23 +442,27 @@ async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result anyhow::Result { +/// Atomically write the catalog cache. Caches the RAW fetched bytes — not a +/// re-serialization of the typed struct — for two reasons: the struct's +/// `apps` HashMap serializes in nondeterministic order (a re-serialized +/// comparison would report "changed" on every poll), and the raw bytes are +/// the signed preimage, so the cache stays signature-verifiable. Returns +/// `false` (skipping the write) when the bytes are identical to what's +/// already cached, so callers can tell a genuine catalog change from an +/// unchanged poll. +fn write_cache(data_dir: &Path, body: &str) -> anyhow::Result { let dest = data_dir.join(APP_CATALOG_FILE); - let json = serde_json::to_string_pretty(catalog)?; if std::fs::read_to_string(&dest) - .map(|current| current == json) + .map(|current| current == body) .unwrap_or(false) { return Ok(false); } let tmp = data_dir.join(format!("{}.tmp", APP_CATALOG_FILE)); - std::fs::write(&tmp, json)?; + std::fs::write(&tmp, body)?; std::fs::rename(&tmp, &dest)?; Ok(true) } @@ -498,25 +505,26 @@ mod tests { fn write_cache_reports_changed_only_on_new_bytes() { // The changed flag gates the runtime manifest-overlay reload: an // unchanged hourly poll must NOT report changed (or every tick would - // rebuild the manifest map), while a genuinely new catalog must. + // rebuild the manifest map), while a genuinely new catalog must. Raw + // fetched bytes are compared — a re-serialized comparison would flap + // on the apps HashMap's nondeterministic key order (seen live on .228). let dir = tempfile::tempdir().unwrap(); - let cat: AppCatalog = serde_json::from_str( - r#"{"schema":1,"apps":{"demo":{"version":"1.0.0"}}}"#, - ) - .unwrap(); - assert!(write_cache(dir.path(), &cat).unwrap(), "first write is a change"); + let body = r#"{"schema":1,"apps":{"demo":{"version":"1.0.0"}}}"#; + assert!(write_cache(dir.path(), body).unwrap(), "first write is a change"); assert!( - !write_cache(dir.path(), &cat).unwrap(), + !write_cache(dir.path(), body).unwrap(), "identical rewrite is not a change" ); - let cat2: AppCatalog = serde_json::from_str( - r#"{"schema":1,"apps":{"demo":{"version":"1.0.1"}}}"#, - ) - .unwrap(); + let body2 = r#"{"schema":1,"apps":{"demo":{"version":"1.0.1"}}}"#; assert!( - write_cache(dir.path(), &cat2).unwrap(), + write_cache(dir.path(), body2).unwrap(), "new catalog bytes are a change" ); + assert_eq!( + std::fs::read_to_string(dir.path().join(APP_CATALOG_FILE)).unwrap(), + body2, + "cache holds the raw signed preimage bytes" + ); } #[test]