Skip to main content

auth_cloudflare/
cache.rs

1//! Cache - account-scoped, atomic cache paths and versioned catalog cache for
2//! the Workers AI catalog.
3//!
4//! Layout: `$HERMES_HOME/cache/auth-cloudflare/<slug>/`. Each account's
5//! catalog is stored as two files written atomically:
6//! - `catalog.json` - the raw payload (`serde_json::Value`);
7//! - `catalog.meta.json` - `CatalogCacheMeta` (schema version, fetch time,
8//!   provenance, model count, account fingerprint).
9//!
10//! Reads return `Ok(None)` when the cache is absent, and `Err` when a present
11//! file is truncated or corrupt - callers fall back to a live fetch or the
12//! bundled fallback catalog (never panic).
13
14use std::path::{Path, PathBuf};
15
16use crate::auth::AuthProvider;
17
18/// Cache payload file name (account-scoped).
19const CATALOG_FILE: &str = "catalog.json";
20/// Cache metadata file name (account-scoped).
21const CATALOG_META_FILE: &str = "catalog.meta.json";
22
23/// Env var overriding the Hermes home (matches `hermes`'s own resolution).
24pub const HERMES_HOME_ENV: &str = "HERMES_HOME";
25
26/// Return the account-scoped cache directory for one provider.
27///
28/// Layout: `$HERMES_HOME/cache/auth-cloudflare/<slug>/` where `<slug>` is the
29/// stable 16-hex FNV-1a account hash (`AuthProvider::cache_slug`). The token
30/// never enters the cache path or any file under it.
31pub fn cache_dir_for_account(provider: &AuthProvider) -> PathBuf {
32	let home = std::env::var(HERMES_HOME_ENV)
33		.ok()
34		.filter(|v| !v.trim().is_empty())
35		.unwrap_or_else(|| format!("{}/.hermes", std::env::var("HOME").unwrap_or_else(|_| "~".to_string())));
36	PathBuf::from(home)
37		.join("cache")
38		.join("auth-cloudflare")
39		.join(provider.cache_slug())
40}
41
42/// Metadata recorded alongside every cached catalog payload (the cache
43/// records timestamps, provenance, and model count).
44#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub struct CatalogCacheMeta {
47	/// Catalog JSON schema version (`crate::schema::CATALOG_SCHEMA_VERSION`).
48	pub schema_version: u32,
49	/// RFC 3339 UTC fetch timestamp (`2026-09-09T15:30:00Z`).
50	pub fetched_at: String,
51	/// Provenance of the payload, e.g. `cloudflare-workers-ai`.
52	pub source: String,
53	/// Number of model records in the payload.
54	pub model_count: usize,
55	/// Opaque, token-free account fingerprint (the cache slug hash).
56	pub account_fingerprint: String,
57}
58
59/// Read the versioned catalog cache for an account-scoped directory.
60///
61/// Returns `Ok(None)` when either cache file is missing (no cache yet).
62/// Returns `Err(CloudflareError::Http)` on I/O failure or when a present file
63/// is truncated/corrupt JSON - the caller falls back to a live fetch or the
64/// bundled fallback catalog. Never panics.
65pub fn read_catalog_cache(
66	dir: &Path,
67) -> Result<Option<(CatalogCacheMeta, serde_json::Value)>, crate::error::CloudflareError> {
68	let meta_path = dir.join(CATALOG_META_FILE);
69	let payload_path = dir.join(CATALOG_FILE);
70	if !meta_path.exists() || !payload_path.exists() {
71		return Ok(None);
72	}
73	let meta_raw = std::fs::read_to_string(&meta_path)
74		.map_err(|e| crate::error::CloudflareError::Http(format!("read {}: {e}", meta_path.display())))?;
75	let payload_raw = std::fs::read_to_string(&payload_path)
76		.map_err(|e| crate::error::CloudflareError::Http(format!("read {}: {e}", payload_path.display())))?;
77	let meta: CatalogCacheMeta = serde_json::from_str(&meta_raw)
78		.map_err(|e| crate::error::CloudflareError::Http(format!("parse {}: {e}", meta_path.display())))?;
79	let payload: serde_json::Value = serde_json::from_str(&payload_raw)
80		.map_err(|e| crate::error::CloudflareError::Http(format!("parse {}: {e}", payload_path.display())))?;
81	Ok(Some((meta, payload)))
82}
83
84/// Write the versioned catalog cache for an account-scoped directory.
85///
86/// Creates `dir` if needed and writes both files atomically (a sibling
87/// `.tmp` file renamed into place, mode `0o600`). The payload is written
88/// first; the meta file is the commit point, so a concurrent or crashed
89/// reader never sees metadata without a fully written payload.
90pub fn write_catalog_cache(
91	dir: &Path,
92	meta: &CatalogCacheMeta,
93	payload: &serde_json::Value,
94) -> Result<(), crate::error::CloudflareError> {
95	std::fs::create_dir_all(dir)
96		.map_err(|e| crate::error::CloudflareError::Http(format!("create {}: {e}", dir.display())))?;
97	let payload_json = serde_json::to_string(payload)
98		.map_err(|e| crate::error::CloudflareError::Http(format!("serialize payload: {e}")))?;
99	let meta_json =
100		serde_json::to_string(meta).map_err(|e| crate::error::CloudflareError::Http(format!("serialize meta: {e}")))?;
101	atomic_write(&dir.join(CATALOG_FILE), &payload_json)?;
102	atomic_write(&dir.join(CATALOG_META_FILE), &meta_json)
103}
104
105/// True when the cache metadata is older than `max_age`.
106///
107/// `fetched_at` is parsed as RFC 3339 via chrono; an unparseable timestamp is
108/// treated as stale (the caller refetches rather than trusting a broken
109/// record). A timestamp in the future is not stale.
110pub fn cache_is_stale(meta: &CatalogCacheMeta, max_age: std::time::Duration) -> bool {
111	let fetched_at = match chrono::DateTime::parse_from_rfc3339(&meta.fetched_at) {
112		Ok(parsed) => parsed,
113		Err(_) => return true,
114	};
115	let max = match chrono::TimeDelta::from_std(max_age) {
116		Ok(d) => d,
117		// Degenerate guard: conversion only fails for absurd durations.
118		Err(_) => chrono::TimeDelta::MAX,
119	};
120	chrono::Utc::now().signed_duration_since(fetched_at) > max
121}
122
123/// Atomic write helper - write to a sibling temp file, then rename over the
124/// destination. Never leave a half-written catalog behind.
125pub fn atomic_write(destination: &std::path::Path, contents: &str) -> Result<(), crate::error::CloudflareError> {
126	use std::io::Write;
127	if let Some(parent) = destination.parent() {
128		std::fs::create_dir_all(parent).map_err(|e| crate::error::CloudflareError::Http(e.to_string()))?;
129	}
130	let temp = destination.with_extension("tmp");
131	{
132		let mut file = std::fs::File::create(&temp).map_err(|e| crate::error::CloudflareError::Http(e.to_string()))?;
133		file.write_all(contents.as_bytes())
134			.map_err(|e| crate::error::CloudflareError::Http(e.to_string()))?;
135		file.write_all(b"\n")
136			.map_err(|e| crate::error::CloudflareError::Http(e.to_string()))?;
137	}
138	std::fs::rename(&temp, destination).map_err(|e| crate::error::CloudflareError::Http(e.to_string()))?;
139	// 0o600 - user-private operational metadata.
140	#[cfg(unix)]
141	{
142		use std::os::unix::fs::PermissionsExt;
143		let _ = std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o600));
144	}
145	Ok(())
146}
147
148#[cfg(test)]
149mod tests {
150	use super::*;
151	use crate::schema::CATALOG_SCHEMA_VERSION;
152
153	/// Unique scratch dir per test - tests run in parallel.
154	fn scratch_dir(name: &str) -> PathBuf {
155		std::env::temp_dir().join(format!("auth-cloudflare-cache-test-{}-{name}", std::process::id()))
156	}
157
158	#[test]
159	fn cache_dir_uses_auth_cloudflare_root_and_slug() {
160		let provider = AuthProvider::new("test-account");
161		let dir = cache_dir_for_account(&provider);
162		assert!(dir.ends_with(provider.cache_slug()));
163		let rendered = dir.to_string_lossy();
164		assert!(rendered.contains("cache/auth-cloudflare"));
165		assert!(!rendered.contains("cache/cloudflare/"));
166	}
167
168	#[test]
169	fn atomic_write_renames_over() {
170		let dir = scratch_dir("atomic-rename");
171		std::fs::create_dir_all(&dir).expect("create scratch dir");
172		let destination = dir.join("catalog.json");
173		atomic_write(&destination, "{}").expect("first write");
174		atomic_write(&destination, "{\"v\":2}").expect("overwrite");
175		let contents = std::fs::read_to_string(&destination).expect("read");
176		assert!(contents.trim() == "{\"v\":2}");
177		// No stray temp file left behind.
178		assert!(!destination.with_extension("tmp").exists());
179		let _ = std::fs::remove_dir_all(&dir);
180	}
181
182	#[test]
183	fn write_read_roundtrip_preserves_meta_and_payload() {
184		let dir = scratch_dir("roundtrip");
185		let meta = CatalogCacheMeta {
186			schema_version: CATALOG_SCHEMA_VERSION,
187			fetched_at: "2026-09-09T15:30:00Z".to_string(),
188			source: "cloudflare-workers-ai".to_string(),
189			model_count: 27,
190			account_fingerprint: "0123456789abcdef".to_string(),
191		};
192		let payload = serde_json::json!({
193			"schema_version": 1,
194			"models": [{"id": "@cf/deepseek-ai/deepseek-v4-flash-0731"}]
195		});
196		write_catalog_cache(&dir, &meta, &payload).expect("write");
197		let (read_meta, read_payload) = read_catalog_cache(&dir).expect("read").expect("cache present");
198		assert_eq!(read_meta, meta);
199		assert_eq!(read_payload, payload);
200		// Both files exist with no stray temps.
201		assert!(dir.join(CATALOG_META_FILE).exists());
202		assert!(dir.join(CATALOG_FILE).exists());
203		assert!(!dir.join("catalog.tmp").exists());
204		assert!(!dir.join("catalog.meta.tmp").exists());
205		let _ = std::fs::remove_dir_all(&dir);
206	}
207
208	#[test]
209	fn read_missing_cache_returns_none() {
210		let dir = scratch_dir("missing");
211		let _ = std::fs::remove_dir_all(&dir);
212		assert!(read_catalog_cache(&dir).expect("read").is_none());
213		// Only meta present, payload absent -> treated as no cache.
214		std::fs::create_dir_all(&dir).expect("create");
215		atomic_write(&dir.join(CATALOG_META_FILE), "{}").expect("meta write");
216		assert!(read_catalog_cache(&dir).expect("read").is_none());
217		let _ = std::fs::remove_dir_all(&dir);
218	}
219
220	#[test]
221	fn corrupt_cache_file_returns_err_without_panic() {
222		let dir = scratch_dir("corrupt");
223		let meta = CatalogCacheMeta {
224			schema_version: CATALOG_SCHEMA_VERSION,
225			fetched_at: "2026-09-09T15:30:00Z".to_string(),
226			source: "cloudflare-workers-ai".to_string(),
227			model_count: 27,
228			account_fingerprint: "0123456789abcdef".to_string(),
229		};
230		write_catalog_cache(&dir, &meta, &serde_json::json!({"models": []})).expect("write");
231
232		// Truncated/corrupt payload -> Err, no panic.
233		std::fs::write(dir.join(CATALOG_FILE), b"{\"models\": [truncated").expect("corrupt payload");
234		assert!(read_catalog_cache(&dir).is_err());
235
236		// Corrupt meta -> Err, no panic.
237		write_catalog_cache(&dir, &meta, &serde_json::json!({"models": []})).expect("rewrite");
238		std::fs::write(dir.join(CATALOG_META_FILE), b"not json at all{").expect("corrupt meta");
239		assert!(read_catalog_cache(&dir).is_err());
240
241		let _ = std::fs::remove_dir_all(&dir);
242	}
243
244	#[test]
245	fn stale_detection_fresh_vs_old() {
246		let max_age = std::time::Duration::from_secs(3600);
247		let fresh = CatalogCacheMeta {
248			schema_version: CATALOG_SCHEMA_VERSION,
249			fetched_at: chrono::Utc::now().to_rfc3339(),
250			source: "cloudflare-workers-ai".to_string(),
251			model_count: 27,
252			account_fingerprint: "0123456789abcdef".to_string(),
253		};
254		assert!(!cache_is_stale(&fresh, max_age));
255
256		let ten_year_old = CatalogCacheMeta { fetched_at: "2016-01-01T00:00:00Z".to_string(), ..fresh.clone() };
257		assert!(cache_is_stale(&ten_year_old, max_age));
258
259		// Unparseable timestamp -> stale.
260		let broken = CatalogCacheMeta { fetched_at: "yesterday-ish".to_string(), ..fresh.clone() };
261		assert!(cache_is_stale(&broken, max_age));
262	}
263}