1use std::path::{Path, PathBuf};
16
17use crate::auth::AuthProvider;
18
19const CATALOG_FILE: &str = "catalog.json";
21const CATALOG_META_FILE: &str = "catalog.meta.json";
23
24pub const HERMES_HOME_ENV: &str = "HERMES_HOME";
26
27pub 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub struct CatalogCacheMeta {
48 pub schema_version: u32,
50 pub fetched_at: String,
52 pub source: String,
54 pub model_count: usize,
56 pub account_fingerprint: String,
58}
59
60pub 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
85pub 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
106pub 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 Err(_) => chrono::TimeDelta::MAX,
120 };
121 chrono::Utc::now().signed_duration_since(fetched_at) > max
122}
123
124pub 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 #[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 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 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 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 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 std::fs::write(dir.join(CATALOG_FILE), b"{\"models\": [truncated").expect("corrupt payload");
235 assert!(read_catalog_cache(&dir).is_err());
236
237 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 let broken = CatalogCacheMeta { fetched_at: "yesterday-ish".to_string(), ..fresh.clone() };
262 assert!(cache_is_stale(&broken, max_age));
263 }
264}