fix(catalog): compare/cache raw catalog bytes, not a re-serialization
Live verification on .228 showed catalog_changed:true on every check-updates call: AppCatalog.apps is a HashMap, so the re-serialized cache bytes flap with key order and the changed-detection never reports "unchanged" — which would have reloaded the manifest overlay every hourly poll. Cache the raw fetched body instead and compare against that; as a bonus the cache now holds the exact signed preimage, so a present release-root signature stays verifiable from disk. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ceb319c292
commit
7e866c7c94
@ -373,9 +373,9 @@ pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<CatalogRefresh>
|
||||
let mut last_err: Option<anyhow::Error> = 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<CatalogRefresh>
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable")))
|
||||
}
|
||||
|
||||
async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<AppCatalog> {
|
||||
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<AppCat
|
||||
}
|
||||
}
|
||||
|
||||
Ok(catalog)
|
||||
Ok((catalog, body))
|
||||
}
|
||||
|
||||
/// Atomically write the catalog cache. Returns `false` (skipping the write)
|
||||
/// when the serialized 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, catalog: &AppCatalog) -> anyhow::Result<bool> {
|
||||
/// 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<bool> {
|
||||
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]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user