Skip to main content

majordomus_cli/environment/
cache.rs

1//! The snapshot cache: the two or three answers a fast resolution cannot afford to
2//! compute, kept beside the shared server's lease under the checkout-local half of the
3//! layer.
4//!
5//! # Why there is a cache at all
6//!
7//! Counting what the layer holds means building the index, which reads and validates every
8//! declared file. That is the right cost for a command a person waits on and an impossible
9//! one for a shell prompt. So a full resolution writes what it learnt, and a fast one
10//! reads it back.
11//!
12//! # Why it is three caches and not one
13//!
14//! Each tier carries its own fingerprint over its own inputs, so editing the justfile
15//! expires the workflows and leaves the counts alone, and committing a rule expires the
16//! counts and leaves the workflows alone. One fingerprint over everything would make every
17//! change invalidate everything, which is the cheap version of this design and the one
18//! that makes a warm cache rare.
19//!
20//! # What is in it, and what may never be
21//!
22//! Counts, recipe names and their doc comments, toolchain version strings, and a digest of
23//! the last snapshot. Nothing read from the environment, no file contents, no paths
24//! outside the repository, and nothing a credential could be hiding in — asserted by
25//! `the_cache_holds_nothing_that_could_be_a_secret`.
26//!
27//! A cache that cannot be read is a cache miss, never an error: a truncated write, a file
28//! from another version, a directory that is read-only. Writes are atomic — a temporary
29//! file and a rename — so a reader sees either the previous entry or the whole new one.
30
31use std::path::{Path, PathBuf};
32use std::time::{Duration, SystemTime};
33
34use serde::{Deserialize, Serialize};
35
36use super::{LayerSummary, ToolchainState, WorkflowCatalogue};
37
38/// Where the cache lives, relative to the checkout-local half of the layer (`.ai/local/`).
39/// Beside the shared server's lease, for the same reason: it belongs to this checkout, it
40/// is never tracked, and a fresh clone starts without it by design.
41pub const CACHE_PATH: &str = "state/environment/snapshot.json";
42
43/// The cache document's own schema. A document of any other schema is a miss.
44pub const SCHEMA: &str = "majordomus-environment-cache/v1";
45
46/// How long an installed toolchain version is believed. Nothing in the repository changes
47/// when a person upgrades their compiler, so no fingerprint over the repository can notice
48/// it; a bounded life is what makes that self-heal instead of lying until the next full
49/// resolution.
50pub const TOOLCHAIN_LIFETIME: Duration = Duration::from_secs(6 * 3600);
51
52/// One cached answer with the fingerprint of the inputs it was computed from.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct Entry<T> {
55    /// The fingerprint of this tier's inputs when the value was computed.
56    pub fingerprint: String,
57    /// When it was written, seconds since the Unix epoch.
58    pub written_at: u64,
59    /// The value.
60    pub value: T,
61}
62
63impl<T> Entry<T> {
64    /// The value, when the fingerprint still matches and the entry has not outlived
65    /// `lifetime`.
66    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/// What a fast resolution takes from an earlier full one.
81#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
82pub struct CachedTier {
83    /// What the layer holds; expires when the layer changes.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub layer: Option<Entry<LayerSummary>>,
86    /// The workflows; expires when the workflow files change.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub workflows: Option<Entry<WorkflowCatalogue>>,
89    /// The installed toolchain versions; expires with the markers, and with time.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub toolchains: Option<Entry<Vec<ToolchainState>>>,
92    /// The rule tally a full preflight counted, for the entry path, which never builds the
93    /// index. It carries the commit and tree it was counted at and the preflight judges it
94    /// stale against any other, so it needs no fingerprint of its own.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub rules: Option<Entry<super::preflight::RulesTally>>,
97    /// For each commit the evidence ledger records a run at, whether the tree in front of
98    /// you is that commit's tree (the ledger's own row excluded). Each answer costs git two
99    /// processes, and entry would pay them on every `cd`; the fingerprint covers HEAD, every
100    /// path git reports changed and the ledger file itself, so any of those moving recomputes.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub ledger_trees: Option<Entry<std::collections::BTreeMap<String, bool>>>,
103    /// The digest of the last snapshot rendered to a person, so that a banner in `auto`
104    /// mode can tell a first look from a re-entry. Not a record of anything a person did:
105    /// one digest, overwritten, naming nothing about the session that saw it.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub last_rendered_digest: Option<String>,
108}
109
110/// The cache file of one checkout.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct Cache {
113    /// [`SCHEMA`].
114    pub schema: String,
115    /// The executable that wrote it: a build with different types must not read another's
116    /// entries back into them.
117    pub version: String,
118    /// The tiers.
119    #[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    /// Where the cache of this checkout is.
135    pub fn path(root: &Path, local_half: &str) -> PathBuf {
136        root.join(local_half).join(CACHE_PATH)
137    }
138
139    /// Read the cache, or a fresh empty one. Never fails: an unreadable, truncated,
140    /// foreign or corrupt file is a miss, and the next write replaces it.
141    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    /// Write the cache atomically. A failure is reported to the caller and is never fatal:
152    /// a read-only checkout must still produce a snapshot, just not a cheaper next one.
153    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        // Unique per process: two resolutions racing must not write the same temporary
159        // file, or one truncates the other's and the rename publishes half a document.
160        let tmp = path.with_extension(format!("json.{}.tmp", std::process::id()));
161        let text = serde_json::to_string(self)?;
162        // A cache that says the same thing is not written again. Since ADR 0043 this runs on
163        // the entry path of every `cd`, and a file whose modification time moves on every
164        // entry is a file somebody is watching: direnv watches paths by mtime, `git status`
165        // stats the tree, a backup tool copies it, and a case asserting that entering a
166        // healthy repository changes nothing on disk fails on a document identical to the one
167        // already there. Comparing first costs one read of a small file and saves a write, a
168        // rename and an inode's worth of churn on the hottest path this executable has.
169        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    /// An entry for a value computed now.
183    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
192/// Seconds since the Unix epoch, or 0 on a machine whose clock is before it.
193pub 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
200/// A fingerprint over a set of files: their paths, sizes and modification times.
201///
202/// Paths are repository-relative and the set is sorted, so the value does not depend on
203/// the order they were offered or on where the checkout lives. A file that does not exist
204/// contributes its absence, which is itself a fact worth invalidating on: deleting the
205/// justfile must expire the workflows.
206///
207/// ```
208/// use majordomus_cli::environment::cache::fingerprint_of;
209/// let dir = tempfile::tempdir().expect("a temporary directory");
210/// std::fs::write(dir.path().join("a"), "1").expect("a file");
211/// let before = fingerprint_of(dir.path(), &["a".into(), "b".into()], &["salt"]);
212/// let same = fingerprint_of(dir.path(), &["a".into(), "b".into()], &["salt"]);
213/// assert_eq!(before, same, "the same inputs give the same fingerprint");
214/// let salted = fingerprint_of(dir.path(), &["a".into(), "b".into()], &["other"]);
215/// assert_ne!(before, salted, "the salt is part of it");
216/// ```
217pub 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    /// Changing one tier's inputs must not cost the others their entries. This is the
328    /// whole reason the fingerprints are per tier.
329    #[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    /// The cache is written to a checkout a person may share, and read back into a typed
365    /// model that reaches an API. Whatever else it holds, it must not be able to hold a
366    /// secret: every field of every tier is a count, a name, a doc comment or a version.
367    #[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        // and nothing that looks like an absolute path out of the checkout
392        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        // Whether the platform enforces it is not the assertion; that a failure is
413        // returned rather than panicking, and that loading still answers, is.
414        let _ = written;
415        assert_eq!(Cache::load(dir.path(), ".ai/local"), Cache::default());
416    }
417}