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