majordomus_cli/environment/
cache.rs1use std::path::{Path, PathBuf};
32use std::time::{Duration, SystemTime};
33
34use serde::{Deserialize, Serialize};
35
36use super::{LayerSummary, ToolchainState, WorkflowCatalogue};
37
38pub const CACHE_PATH: &str = "state/environment/snapshot.json";
42
43pub const SCHEMA: &str = "majordomus-environment-cache/v1";
45
46pub const TOOLCHAIN_LIFETIME: Duration = Duration::from_secs(6 * 3600);
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct Entry<T> {
55 pub fingerprint: String,
57 pub written_at: u64,
59 pub value: T,
61}
62
63impl<T> Entry<T> {
64 pub fn fresh(&self, fingerprint: &str, lifetime: Option<Duration>) -> Option<&T> {
67 if self.fingerprint != fingerprint {
68 return None;
69 }
70 if let Some(lifetime) = lifetime {
71 let age = now_seconds().saturating_sub(self.written_at);
72 if age > lifetime.as_secs() {
73 return None;
74 }
75 }
76 Some(&self.value)
77 }
78}
79
80#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
82pub struct CachedTier {
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub layer: Option<Entry<LayerSummary>>,
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub workflows: Option<Entry<WorkflowCatalogue>>,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub toolchains: Option<Entry<Vec<ToolchainState>>>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub rules: Option<Entry<super::preflight::RulesTally>>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub ledger_trees: Option<Entry<std::collections::BTreeMap<String, bool>>>,
103 #[serde(skip_serializing_if = "Option::is_none")]
107 pub last_rendered_digest: Option<String>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct Cache {
113 pub schema: String,
115 pub version: String,
118 #[serde(default)]
120 pub tiers: CachedTier,
121}
122
123impl Default for Cache {
124 fn default() -> Self {
125 Cache {
126 schema: SCHEMA.into(),
127 version: crate::VERSION.into(),
128 tiers: CachedTier::default(),
129 }
130 }
131}
132
133impl Cache {
134 pub fn path(root: &Path, local_half: &str) -> PathBuf {
136 root.join(local_half).join(CACHE_PATH)
137 }
138
139 pub fn load(root: &Path, local_half: &str) -> Cache {
142 let Ok(text) = std::fs::read_to_string(Self::path(root, local_half)) else {
143 return Cache::default();
144 };
145 match serde_json::from_str::<Cache>(&text) {
146 Ok(cache) if cache.schema == SCHEMA && cache.version == crate::VERSION => cache,
147 Ok(_) | Err(_) => Cache::default(),
148 }
149 }
150
151 pub fn store(&self, root: &Path, local_half: &str) -> std::io::Result<()> {
154 let path = Self::path(root, local_half);
155 if let Some(dir) = path.parent() {
156 std::fs::create_dir_all(dir)?;
157 }
158 let tmp = path.with_extension(format!("json.{}.tmp", std::process::id()));
161 let text = serde_json::to_string(self)?;
162 if std::fs::read(&path).is_ok_and(|existing| existing == text.as_bytes()) {
170 return Ok(());
171 }
172 std::fs::write(&tmp, text)?;
173 match std::fs::rename(&tmp, &path) {
174 Ok(()) => Ok(()),
175 Err(e) => {
176 let _ = std::fs::remove_file(&tmp);
177 Err(e)
178 }
179 }
180 }
181
182 pub fn entry<T>(fingerprint: String, value: T) -> Entry<T> {
184 Entry {
185 fingerprint,
186 written_at: now_seconds(),
187 value,
188 }
189 }
190}
191
192pub fn now_seconds() -> u64 {
194 SystemTime::now()
195 .duration_since(SystemTime::UNIX_EPOCH)
196 .map(|d| d.as_secs())
197 .unwrap_or(0)
198}
199
200pub fn fingerprint_of(root: &Path, paths: &[String], salt: &[&str]) -> String {
218 let mut parts: Vec<String> = Vec::with_capacity(paths.len() + salt.len() + 2);
219 parts.push(format!("schema={}", super::SCHEMA_VERSION));
220 parts.push(format!("build={}+{}", crate::VERSION, crate::COMMIT));
221 for s in salt {
222 parts.push(format!("salt={s}"));
223 }
224 let mut sorted: Vec<&String> = paths.iter().collect();
225 sorted.sort();
226 sorted.dedup();
227 for path in sorted {
228 parts.push(match std::fs::metadata(root.join(path)) {
229 Ok(meta) => {
230 let nanos = meta
231 .modified()
232 .ok()
233 .and_then(|m| m.duration_since(SystemTime::UNIX_EPOCH).ok())
234 .map(|d| d.as_nanos())
235 .unwrap_or(0);
236 format!("{path}:{}:{nanos}", meta.len())
237 }
238 Err(_) => format!("{path}:absent"),
239 });
240 }
241 crate::policy::sha256_hex(&parts.join("\n"))
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::environment::TierState;
248
249 fn summary() -> LayerSummary {
250 LayerSummary {
251 state: TierState::Resolved,
252 kinds: vec![super::super::KindCount {
253 kind: "rule".into(),
254 count: 87,
255 }],
256 objects: Some(902),
257 capabilities: Some(934),
258 invalid: Some(0),
259 refused: Vec::new(),
260 degraded: Some(false),
261 }
262 }
263
264 #[test]
265 fn a_miss_is_never_an_error() {
266 let dir = tempfile::tempdir().expect("a temporary directory");
267 assert_eq!(Cache::load(dir.path(), ".ai/local"), Cache::default());
268 }
269
270 #[test]
271 fn a_corrupt_cache_regenerates_rather_than_failing() {
272 let dir = tempfile::tempdir().expect("a temporary directory");
273 let path = Cache::path(dir.path(), ".ai/local");
274 std::fs::create_dir_all(path.parent().expect("a parent")).expect("a directory");
275 for rubbish in ["", "{", "null", r#"{"schema":"other/v9"}"#] {
276 std::fs::write(&path, rubbish).expect("a file");
277 assert_eq!(
278 Cache::load(dir.path(), ".ai/local"),
279 Cache::default(),
280 "{rubbish:?} should be a miss"
281 );
282 }
283 }
284
285 #[test]
286 fn a_cache_written_by_another_build_is_not_read() {
287 let dir = tempfile::tempdir().expect("a temporary directory");
288 let cache = Cache {
289 version: "0.0.0-not-this-build".into(),
290 ..Cache::default()
291 };
292 cache.store(dir.path(), ".ai/local").expect("a write");
293 assert_eq!(Cache::load(dir.path(), ".ai/local").tiers.layer, None);
294 }
295
296 #[test]
297 fn a_stored_entry_comes_back_when_its_fingerprint_still_matches() {
298 let dir = tempfile::tempdir().expect("a temporary directory");
299 let mut cache = Cache::default();
300 cache.tiers.layer = Some(Cache::entry("fp-1".into(), summary()));
301 cache.store(dir.path(), ".ai/local").expect("a write");
302
303 let loaded = Cache::load(dir.path(), ".ai/local");
304 let entry = loaded.tiers.layer.expect("the entry");
305 assert_eq!(
306 entry.fresh("fp-1", None).map(|s| s.objects),
307 Some(Some(902))
308 );
309 assert_eq!(entry.fresh("fp-2", None), None, "a changed input is a miss");
310 }
311
312 #[test]
313 fn an_entry_past_its_lifetime_is_a_miss_even_with_the_same_fingerprint() {
314 let mut entry = Cache::entry("fp".into(), summary());
315 assert!(entry.fresh("fp", Some(Duration::from_secs(60))).is_some());
316 entry.written_at = now_seconds().saturating_sub(7 * 3600);
317 assert!(
318 entry.fresh("fp", Some(TOOLCHAIN_LIFETIME)).is_none(),
319 "an installed version older than its lifetime is not believed"
320 );
321 assert!(
322 entry.fresh("fp", None).is_some(),
323 "a tier with no lifetime does not expire with time"
324 );
325 }
326
327 #[test]
330 fn tiers_expire_independently() {
331 let dir = tempfile::tempdir().expect("a temporary directory");
332 std::fs::write(dir.path().join("justfile"), "a:\n @echo\n").expect("a file");
333 std::fs::write(dir.path().join("manifest"), "x").expect("a file");
334
335 let workflows = fingerprint_of(dir.path(), &["justfile".into()], &["workflows"]);
336 let capabilities = fingerprint_of(dir.path(), &["manifest".into()], &["capabilities"]);
337
338 std::fs::write(dir.path().join("justfile"), "a:\n @echo changed\n").expect("a file");
339
340 assert_ne!(
341 workflows,
342 fingerprint_of(dir.path(), &["justfile".into()], &["workflows"]),
343 "the justfile changed, so the workflows must expire"
344 );
345 assert_eq!(
346 capabilities,
347 fingerprint_of(dir.path(), &["manifest".into()], &["capabilities"]),
348 "and the counts must not"
349 );
350 }
351
352 #[test]
353 fn a_missing_file_fingerprints_as_absent_rather_than_as_nothing() {
354 let dir = tempfile::tempdir().expect("a temporary directory");
355 let absent = fingerprint_of(dir.path(), &["justfile".into()], &[]);
356 std::fs::write(dir.path().join("justfile"), "a:\n").expect("a file");
357 assert_ne!(
358 absent,
359 fingerprint_of(dir.path(), &["justfile".into()], &[]),
360 "creating the file must expire what its absence produced"
361 );
362 }
363
364 #[test]
368 fn the_cache_holds_nothing_that_could_be_a_secret() {
369 let dir = tempfile::tempdir().expect("a temporary directory");
370 let mut cache = Cache::default();
371 cache.tiers.layer = Some(Cache::entry("fp".into(), summary()));
372 cache.tiers.toolchains = Some(Cache::entry(
373 "fp".into(),
374 vec![ToolchainState {
375 id: "rust".into(),
376 title: "Rust".into(),
377 declared: Some("1.85".into()),
378 declared_by: "Cargo.toml".into(),
379 installed: Some("1.90.0".into()),
380 availability: super::super::ToolchainAvailability::Installed,
381 }],
382 ));
383 cache.store(dir.path(), ".ai/local").expect("a write");
384 let text = std::fs::read_to_string(Cache::path(dir.path(), ".ai/local")).expect("the file");
385 for forbidden in ["API_KEY", "TOKEN", "SECRET", "PASSWORD", "Authorization"] {
386 assert!(
387 !text.to_uppercase().contains(forbidden),
388 "the cache carried {forbidden}"
389 );
390 }
391 assert!(
393 !text.contains("/Users/"),
394 "the cache carried a home directory"
395 );
396 assert!(
397 !text.contains("/home/"),
398 "the cache carried a home directory"
399 );
400 }
401
402 #[test]
403 fn a_read_only_checkout_still_resolves_and_only_loses_the_next_shortcut() {
404 let dir = tempfile::tempdir().expect("a temporary directory");
405 let local = dir.path().join(".ai/local/state/environment");
406 std::fs::create_dir_all(&local).expect("a directory");
407 let mut permissions = std::fs::metadata(&local).expect("metadata").permissions();
408 permissions.set_readonly(true);
409 std::fs::set_permissions(&local, permissions).expect("read-only");
410
411 let written = Cache::default().store(dir.path(), ".ai/local");
412 let _ = written;
415 assert_eq!(Cache::load(dir.path(), ".ai/local"), Cache::default());
416 }
417}