Skip to main content

dodot_lib/probe/
brew.rs

1//! Homebrew-cask probe — advisory lookup of cask metadata.
2//!
3//! Implements Phase M6 of `docs/proposals/macos-paths.lex` §8.2. The
4//! cardinal rule from §8 holds: probes are *advisory*, never
5//! authoritative. The symlink resolver in §5 never consults this
6//! module, and a probe failure (no `brew` on PATH, malformed JSON,
7//! cache miss) never alters routing — it just means the user sees a
8//! less-rich suggestion or warning.
9//!
10//! ## What the probe surfaces
11//!
12//! Two shells over `brew`:
13//!
14//! - [`list_installed_casks`] → `brew list --cask --versions`. Cheap.
15//!   Used to short-circuit `brew info` when a token isn't installed.
16//! - [`info_cask`] → `brew info --json=v2 --cask <token>`. Expensive
17//!   the first time (network), so we cache it on disk.
18//!
19//! Plus zap-stanza parsing from the JSON: app-folder candidates,
20//! Application Support entries, and Preferences plists for
21//! sibling-adoption suggestions in `dodot adopt`.
22//!
23//! ## Cache layout
24//!
25//! `<cache_dir>/probes/brew/<token>.json` carries:
26//!
27//! ```json
28//! { "fetched_at": <unix_ts>, "info": { ...brew info JSON... } }
29//! ```
30//!
31//! Entries older than [`CACHE_TTL_SECS`] are treated as stale and
32//! re-fetched. `dodot probe app --refresh` blows the cache for that
33//! pack's tokens.
34
35use std::collections::HashMap;
36use std::path::{Path, PathBuf};
37use std::time::{SystemTime, UNIX_EPOCH};
38
39use serde::{Deserialize, Serialize};
40
41use crate::datastore::CommandRunner;
42use crate::fs::Fs;
43use crate::Result;
44
45/// Cache lifetime in seconds. 24 hours: brew zap data changes rarely
46/// enough that a daily refresh is plenty, and the cost of a
47/// `brew info` call is high enough to want the cache hit.
48pub const CACHE_TTL_SECS: u64 = 24 * 60 * 60;
49
50/// Minimal subset of `brew info --json=v2 --cask <token>` we read.
51///
52/// The full JSON shape is large and brew owns it; we deserialise only
53/// the fields the proposal calls out and tolerate everything else via
54/// `#[serde(default)]` so a brew schema bump doesn't break the probe.
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56pub struct CaskInfo {
57    /// Cask token (e.g. `"visual-studio-code"`).
58    #[serde(default)]
59    pub token: String,
60    /// Human-readable display name (`"Visual Studio Code"`).
61    #[serde(default)]
62    pub name: Vec<String>,
63    /// Bundle filenames declared by the cask's `app` artifact (e.g.
64    /// `["Visual Studio Code.app"]`).
65    #[serde(default)]
66    pub artifacts: Vec<serde_json::Value>,
67    /// Whether the cask is currently installed locally. The brew JSON
68    /// reports this via the `installed` field on each cask entry.
69    #[serde(default)]
70    pub installed: Option<String>,
71}
72
73impl CaskInfo {
74    /// Extract leaf names of `~/Library/Application Support/<X>` paths
75    /// declared in the cask's zap stanza. Each is a candidate
76    /// app-support folder name for matching against an `_app/<X>/`
77    /// pack entry.
78    pub fn app_support_candidates(&self) -> Vec<String> {
79        zap_paths(&self.artifacts)
80            .filter_map(|p| {
81                let needle = "Library/Application Support/";
82                let idx = p.find(needle)?;
83                let rest = &p[idx + needle.len()..];
84                let leaf = rest.split('/').next()?.trim();
85                if leaf.is_empty() {
86                    None
87                } else {
88                    Some(leaf.to_string())
89                }
90            })
91            .collect()
92    }
93
94    /// Preferences plist paths declared in the zap stanza. Used by
95    /// `dodot adopt` to suggest sibling adoptions
96    /// (`~/Library/Preferences/<bundle-id>.plist`).
97    pub fn preferences_plists(&self) -> Vec<String> {
98        zap_paths(&self.artifacts)
99            .filter(|p| p.contains("Library/Preferences/"))
100            .collect()
101    }
102
103    /// `.app` bundle leaf name from the cask's `app` artifact, e.g.
104    /// `"Visual Studio Code.app"`. Used to drive `mdls` lookups.
105    pub fn app_bundle_name(&self) -> Option<String> {
106        for artifact in &self.artifacts {
107            if let Some(arr) = artifact.get("app").and_then(|v| v.as_array()) {
108                if let Some(first) = arr.first().and_then(|v| v.as_str()) {
109                    return Some(first.to_string());
110                }
111            }
112        }
113        None
114    }
115}
116
117/// Iterate every string path declared anywhere in any cask `zap`
118/// stanza. Brew's JSON nests these under `artifacts[].zap[].trash`
119/// and similar arrays; we walk the JSON tree generically rather than
120/// pinning the exact path so a schema tweak doesn't bite us.
121fn zap_paths(artifacts: &[serde_json::Value]) -> impl Iterator<Item = String> + '_ {
122    artifacts.iter().flat_map(|art| {
123        let mut out: Vec<String> = Vec::new();
124        if let Some(zap) = art.get("zap") {
125            walk_strings(zap, &mut out);
126        }
127        out.into_iter()
128    })
129}
130
131fn walk_strings(v: &serde_json::Value, out: &mut Vec<String>) {
132    match v {
133        serde_json::Value::String(s) => out.push(s.clone()),
134        serde_json::Value::Array(a) => {
135            for child in a {
136                walk_strings(child, out);
137            }
138        }
139        serde_json::Value::Object(map) => {
140            for child in map.values() {
141                walk_strings(child, out);
142            }
143        }
144        _ => {}
145    }
146}
147
148/// On-disk cache wrapper around [`CaskInfo`].
149#[derive(Debug, Clone, Serialize, Deserialize)]
150struct CacheEntry {
151    fetched_at: u64,
152    info: CaskInfo,
153}
154
155/// Run `brew list --cask --versions` and return the set of installed
156/// cask tokens. Empty on non-macOS or when `brew` isn't on PATH.
157///
158/// This is intentionally lossy: any error path returns an empty set,
159/// not a `Result::Err`. The probe is advisory — a failure to enumerate
160/// installed casks must never block adopt or up.
161pub fn list_installed_casks(runner: &dyn CommandRunner) -> Vec<String> {
162    if !cfg!(target_os = "macos") {
163        return Vec::new();
164    }
165    let output = match runner.run(
166        "brew",
167        &["list".into(), "--cask".into(), "--versions".into()],
168    ) {
169        Ok(o) if o.exit_code == 0 => o,
170        _ => return Vec::new(),
171    };
172    output
173        .stdout
174        .lines()
175        .filter_map(|line| line.split_whitespace().next().map(str::to_string))
176        .collect()
177}
178
179/// Look up `brew info --json=v2 --cask <token>` with on-disk caching.
180///
181/// `now_secs` is the wall-clock unix timestamp the caller considers
182/// "now" for TTL evaluation; passing it in keeps the cache testable
183/// without a clock dependency. Production callers pass
184/// `SystemTime::now()` via [`now_secs_unix`].
185///
186/// Returns `Ok(None)` when the cask is unknown to brew, or when we're
187/// running on a host without `brew`. Returns `Ok(Some(_))` for a
188/// fresh cache hit, a stale-entry refresh, or a successful first
189/// fetch.
190///
191/// Cache writes are best-effort — a non-writable cache dir downgrades
192/// the probe to "no caching" but doesn't propagate the error.
193pub fn info_cask(
194    token: &str,
195    cache_dir: &Path,
196    now_secs: u64,
197    fs: &dyn Fs,
198    runner: &dyn CommandRunner,
199) -> Result<Option<CaskInfo>> {
200    if !cfg!(target_os = "macos") {
201        return Ok(None);
202    }
203    let cache_path = cache_path_for(cache_dir, token);
204    if let Some(entry) = read_cache(&cache_path, fs) {
205        if now_secs.saturating_sub(entry.fetched_at) < CACHE_TTL_SECS {
206            return Ok(Some(entry.info));
207        }
208    }
209
210    let info = match fetch_from_brew(token, runner) {
211        Some(i) => i,
212        None => return Ok(None),
213    };
214
215    let entry = CacheEntry {
216        fetched_at: now_secs,
217        info: info.clone(),
218    };
219    let _ = write_cache(&cache_path, &entry, fs);
220    Ok(Some(info))
221}
222
223/// Force a refresh by deleting any cached entry for `token`. Errors
224/// are swallowed — the cache is best-effort.
225pub fn invalidate_cache(token: &str, cache_dir: &Path, fs: &dyn Fs) {
226    let path = cache_path_for(cache_dir, token);
227    if fs.exists(&path) {
228        let _ = fs.remove_file(&path);
229    }
230}
231
232fn cache_path_for(cache_dir: &Path, token: &str) -> PathBuf {
233    // Token can contain `/` in theory (formula taps); flatten to a safe
234    // filename. Brew cask tokens in practice are kebab-case ASCII, but
235    // a defensive replace keeps the cache key total.
236    let safe = token.replace(['/', '\\', ':', ' '], "_");
237    cache_dir.join(format!("{safe}.json"))
238}
239
240fn read_cache(path: &Path, fs: &dyn Fs) -> Option<CacheEntry> {
241    if !fs.exists(path) {
242        return None;
243    }
244    let bytes = fs.read_to_string(path).ok()?;
245    serde_json::from_str(&bytes).ok()
246}
247
248fn write_cache(path: &Path, entry: &CacheEntry, fs: &dyn Fs) -> Result<()> {
249    if let Some(parent) = path.parent() {
250        if !fs.exists(parent) {
251            fs.mkdir_all(parent)?;
252        }
253    }
254    let json = serde_json::to_string(entry)
255        .map_err(|e| crate::DodotError::Other(format!("brew cache encode failed: {e}")))?;
256    fs.write_file(path, json.as_bytes())?;
257    Ok(())
258}
259
260fn fetch_from_brew(token: &str, runner: &dyn CommandRunner) -> Option<CaskInfo> {
261    let output = runner
262        .run(
263            "brew",
264            &[
265                "info".into(),
266                "--json=v2".into(),
267                "--cask".into(),
268                token.to_string(),
269            ],
270        )
271        .ok()?;
272    if output.exit_code != 0 {
273        return None;
274    }
275    parse_info_json(&output.stdout)
276}
277
278/// Parse a `brew info --json=v2 --cask` payload, returning the first
279/// cask entry (brew nests them under `casks: []`).
280fn parse_info_json(stdout: &str) -> Option<CaskInfo> {
281    #[derive(Deserialize)]
282    struct Wrapper {
283        #[serde(default)]
284        casks: Vec<CaskInfo>,
285    }
286    let w: Wrapper = serde_json::from_str(stdout).ok()?;
287    w.casks.into_iter().next()
288}
289
290/// Wall-clock unix timestamp helper used by callers that want the
291/// real current time. Tests pass a fixed value so cache TTL is
292/// deterministic.
293pub fn now_secs_unix() -> u64 {
294    SystemTime::now()
295        .duration_since(UNIX_EPOCH)
296        .map(|d| d.as_secs())
297        .unwrap_or(0)
298}
299
300/// Outcome of a folder-to-cask matching pass.
301///
302/// `installed_tokens` carries the result of `brew list --cask
303/// --versions` so callers don't re-spawn the same subprocess for it.
304/// `folder_to_token` is the actual matches: each entry pairs a
305/// pack-relative folder name with the cask token that declares it in
306/// its zap stanza.
307#[derive(Debug, Clone, Default)]
308pub struct InstalledCaskMatches {
309    pub installed_tokens: Vec<String>,
310    pub folder_to_token: HashMap<String, String>,
311}
312
313/// Match `app_aliases` map values + every pack-relative `_app/<X>/...`
314/// folder against installed casks' Application Support candidates.
315///
316/// **Installed-only** — this iterates only the tokens
317/// `brew list --cask --versions` reports. A cask the user hasn't
318/// installed will never appear here. The name reflects that. If you
319/// need broader matching, you'd have to drive it off some other
320/// source (zap data isn't available for non-installed casks without
321/// further `brew info` calls per known token).
322///
323/// `cache_only` controls whether a cache miss triggers a fresh
324/// `brew info --json=v2 --cask <token>` subprocess: callers on a hot
325/// path (planner hints during `up`/`status`) pass `true` so a stale
326/// cache silently degrades to "no enrichment" rather than spawning
327/// dozens of subprocesses; the on-demand `dodot probe app` subcommand
328/// passes `false` to populate the cache fully.
329pub fn match_folders_to_installed_casks(
330    folders: &[String],
331    runner: &dyn CommandRunner,
332    cache_dir: &Path,
333    now_secs: u64,
334    fs: &dyn Fs,
335    cache_only: bool,
336) -> InstalledCaskMatches {
337    let mut out = InstalledCaskMatches::default();
338    if !cfg!(target_os = "macos") {
339        return out;
340    }
341    out.installed_tokens = list_installed_casks(runner);
342    for token in &out.installed_tokens {
343        let info = if cache_only {
344            // Cache-only mode: read the on-disk entry if fresh, never
345            // spawn `brew info`. A miss leaves this token unmatched.
346            read_cache(&cache_path_for(cache_dir, token), fs)
347                .filter(|e| now_secs.saturating_sub(e.fetched_at) < CACHE_TTL_SECS)
348                .map(|e| e.info)
349        } else {
350            info_cask(token, cache_dir, now_secs, fs, runner)
351                .ok()
352                .flatten()
353        };
354        if let Some(info) = info {
355            for cand in info.app_support_candidates() {
356                if folders.iter().any(|f| f == &cand) {
357                    out.folder_to_token.insert(cand, token.clone());
358                }
359            }
360        }
361    }
362    out
363}
364
365/// Best-effort wipe of the entire brew probe cache directory.
366///
367/// `dodot probe app --refresh` calls this so the user's "I want
368/// fresh data" gesture isn't bottlenecked by per-token invalidation
369/// requiring the caller to know which tokens to invalidate (which
370/// they wouldn't, before matching).
371pub fn invalidate_all_cache(cache_dir: &Path, fs: &dyn Fs) {
372    if !fs.exists(cache_dir) {
373        return;
374    }
375    if let Ok(entries) = fs.read_dir(cache_dir) {
376        for entry in entries {
377            if entry.name.ends_with(".json") {
378                let _ = fs.remove_file(&entry.path);
379            }
380        }
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::datastore::CommandOutput;
388    use std::sync::Mutex;
389
390    /// CommandRunner mock that returns canned outputs per command.
391    struct MockRunner {
392        responses: Mutex<HashMap<Vec<String>, CommandOutput>>,
393        calls: Mutex<Vec<Vec<String>>>,
394    }
395
396    impl MockRunner {
397        fn new() -> Self {
398            Self {
399                responses: Mutex::new(HashMap::new()),
400                calls: Mutex::new(Vec::new()),
401            }
402        }
403        fn respond(&self, args: &[&str], stdout: &str, exit_code: i32) {
404            let key: Vec<String> = args.iter().map(|s| s.to_string()).collect();
405            self.responses.lock().unwrap().insert(
406                key,
407                CommandOutput {
408                    exit_code,
409                    stdout: stdout.into(),
410                    stderr: String::new(),
411                },
412            );
413        }
414        fn call_count(&self, args: &[&str]) -> usize {
415            let key: Vec<String> = args.iter().map(|s| s.to_string()).collect();
416            self.calls
417                .lock()
418                .unwrap()
419                .iter()
420                .filter(|c| **c == key)
421                .count()
422        }
423    }
424
425    impl CommandRunner for MockRunner {
426        fn run(&self, exe: &str, args: &[String]) -> Result<CommandOutput> {
427            let mut full = vec![exe.to_string()];
428            full.extend(args.iter().cloned());
429            self.calls.lock().unwrap().push(full.clone());
430            // Strip the executable prefix for response lookup so test
431            // fixtures stay readable.
432            let key: Vec<String> = full.iter().skip(1).cloned().collect();
433            self.responses
434                .lock()
435                .unwrap()
436                .get(&key)
437                .cloned()
438                .ok_or_else(|| crate::DodotError::Other(format!("no mock response for {full:?}")))
439        }
440    }
441
442    fn make_env() -> (crate::testing::TempEnvironment, std::path::PathBuf) {
443        let env = crate::testing::TempEnvironment::builder().build();
444        let cache = env.home.join("brew-probe-cache");
445        env.fs.mkdir_all(&cache).unwrap();
446        (env, cache)
447    }
448
449    #[test]
450    fn parse_info_json_extracts_first_cask() {
451        let payload = r#"{
452            "casks": [
453                {
454                    "token": "visual-studio-code",
455                    "name": ["Visual Studio Code"],
456                    "installed": "1.95.0",
457                    "artifacts": [
458                        {"app": ["Visual Studio Code.app"]},
459                        {"zap": [
460                            {"trash": [
461                                "~/Library/Application Support/Code",
462                                "~/Library/Preferences/com.microsoft.VSCode.plist"
463                            ]}
464                        ]}
465                    ]
466                }
467            ]
468        }"#;
469        let info = parse_info_json(payload).expect("parse");
470        assert_eq!(info.token, "visual-studio-code");
471        assert_eq!(info.installed.as_deref(), Some("1.95.0"));
472        assert_eq!(
473            info.app_bundle_name().as_deref(),
474            Some("Visual Studio Code.app")
475        );
476        let candidates = info.app_support_candidates();
477        assert!(candidates.iter().any(|c| c == "Code"), "got {candidates:?}");
478        let plists = info.preferences_plists();
479        assert!(
480            plists
481                .iter()
482                .any(|p| p.contains("com.microsoft.VSCode.plist")),
483            "got {plists:?}"
484        );
485    }
486
487    #[test]
488    fn parse_info_json_missing_casks_array_returns_none() {
489        assert!(parse_info_json("{}").is_none());
490        assert!(parse_info_json("not json").is_none());
491    }
492
493    #[test]
494    #[cfg_attr(not(target_os = "macos"), ignore = "macOS-only behavior")]
495    fn info_cask_caches_first_result_then_serves_from_cache() {
496        let (env, cache) = make_env();
497        let runner = MockRunner::new();
498        runner.respond(
499            &["info", "--json=v2", "--cask", "visual-studio-code"],
500            r#"{"casks": [{"token": "visual-studio-code", "installed": "1.95.0"}]}"#,
501            0,
502        );
503
504        let now = 1_000_000;
505        let first = info_cask("visual-studio-code", &cache, now, env.fs.as_ref(), &runner)
506            .unwrap()
507            .expect("first call returns Some");
508        assert_eq!(first.token, "visual-studio-code");
509        assert_eq!(
510            runner.call_count(&["brew", "info", "--json=v2", "--cask", "visual-studio-code"]),
511            1
512        );
513
514        // Within TTL → cache hit, no second subprocess.
515        let _ = info_cask(
516            "visual-studio-code",
517            &cache,
518            now + 100,
519            env.fs.as_ref(),
520            &runner,
521        )
522        .unwrap();
523        assert_eq!(
524            runner.call_count(&["brew", "info", "--json=v2", "--cask", "visual-studio-code"]),
525            1,
526            "fresh cache must not re-fetch"
527        );
528    }
529
530    #[test]
531    #[cfg_attr(not(target_os = "macos"), ignore = "macOS-only behavior")]
532    fn info_cask_refetches_when_ttl_expires() {
533        let (env, cache) = make_env();
534        let runner = MockRunner::new();
535        runner.respond(
536            &["info", "--json=v2", "--cask", "cursor"],
537            r#"{"casks": [{"token": "cursor"}]}"#,
538            0,
539        );
540
541        let now = 1_000_000;
542        let _ = info_cask("cursor", &cache, now, env.fs.as_ref(), &runner).unwrap();
543        // Simulate clock advance past TTL.
544        let _ = info_cask(
545            "cursor",
546            &cache,
547            now + CACHE_TTL_SECS + 1,
548            env.fs.as_ref(),
549            &runner,
550        )
551        .unwrap();
552        assert_eq!(
553            runner.call_count(&["brew", "info", "--json=v2", "--cask", "cursor"]),
554            2,
555            "stale cache should re-fetch"
556        );
557    }
558
559    #[test]
560    #[cfg_attr(not(target_os = "macos"), ignore = "macOS-only behavior")]
561    fn invalidate_cache_forces_refetch() {
562        let (env, cache) = make_env();
563        let runner = MockRunner::new();
564        runner.respond(
565            &["info", "--json=v2", "--cask", "zed"],
566            r#"{"casks": [{"token": "zed"}]}"#,
567            0,
568        );
569
570        let now = 1_000_000;
571        let _ = info_cask("zed", &cache, now, env.fs.as_ref(), &runner).unwrap();
572        invalidate_cache("zed", &cache, env.fs.as_ref());
573        let _ = info_cask("zed", &cache, now + 10, env.fs.as_ref(), &runner).unwrap();
574        assert_eq!(
575            runner.call_count(&["brew", "info", "--json=v2", "--cask", "zed"]),
576            2
577        );
578    }
579
580    #[test]
581    #[cfg_attr(not(target_os = "macos"), ignore = "macOS-only behavior")]
582    fn info_cask_returns_none_on_brew_failure() {
583        let (env, cache) = make_env();
584        let runner = MockRunner::new();
585        runner.respond(
586            &["info", "--json=v2", "--cask", "nonexistent"],
587            "",
588            1, // brew exits non-zero on unknown cask
589        );
590        let got = info_cask("nonexistent", &cache, 100, env.fs.as_ref(), &runner).unwrap();
591        assert!(got.is_none());
592    }
593
594    #[test]
595    #[cfg_attr(not(target_os = "macos"), ignore = "macOS-only behavior")]
596    fn list_installed_casks_parses_first_column() {
597        let runner = MockRunner::new();
598        runner.respond(
599            &["list", "--cask", "--versions"],
600            "visual-studio-code 1.95.0\ncursor 0.42.0\nzed 0.150.0\n",
601            0,
602        );
603        let got = list_installed_casks(&runner);
604        assert_eq!(got, vec!["visual-studio-code", "cursor", "zed"]);
605    }
606
607    #[test]
608    fn list_installed_casks_silent_on_non_macos() {
609        // The function is gated by cfg!(target_os = "macos"). On every
610        // other host it returns empty regardless of mock state.
611        let runner = MockRunner::new();
612        let got = list_installed_casks(&runner);
613        if !cfg!(target_os = "macos") {
614            assert!(got.is_empty());
615        }
616        // On macOS hosts we'd hit the "no mock response" path which
617        // returns Err → empty Vec via the function's loss-tolerant
618        // outer match. Still expect no panic either way.
619    }
620
621    #[test]
622    #[cfg_attr(not(target_os = "macos"), ignore = "macOS-only behavior")]
623    fn match_folders_cache_only_skips_brew_info_on_miss() {
624        // With cache_only=true and an empty cache, the matcher must
625        // not call `brew info` — the planner path uses this mode to
626        // keep `up`/`status` fast.
627        let (env, cache) = make_env();
628        let runner = MockRunner::new();
629        runner.respond(
630            &["list", "--cask", "--versions"],
631            "visual-studio-code 1.95.0\n",
632            0,
633        );
634        // No brew info response registered: a call would fail
635        // CannedRunner's lookup → propagating to info_cask returning
636        // None silently. We assert the call count remains 0.
637
638        let now = 1_000_000;
639        let result = match_folders_to_installed_casks(
640            &["Code".into()],
641            &runner,
642            &cache,
643            now,
644            env.fs.as_ref(),
645            /*cache_only=*/ true,
646        );
647        // Installed list still populated (brew list was called).
648        assert!(result
649            .installed_tokens
650            .contains(&"visual-studio-code".into()));
651        // No info → no folder match.
652        assert!(result.folder_to_token.is_empty());
653        // And brew info was never invoked.
654        assert_eq!(
655            runner.call_count(&["brew", "info", "--json=v2", "--cask", "visual-studio-code"]),
656            0,
657            "cache_only=true must not spawn brew info"
658        );
659    }
660
661    #[test]
662    #[cfg_attr(not(target_os = "macos"), ignore = "macOS-only behavior")]
663    fn invalidate_all_cache_clears_every_token() {
664        let (env, cache) = make_env();
665        let runner = MockRunner::new();
666        runner.respond(
667            &["info", "--json=v2", "--cask", "alpha"],
668            r#"{"casks": [{"token": "alpha"}]}"#,
669            0,
670        );
671        runner.respond(
672            &["info", "--json=v2", "--cask", "beta"],
673            r#"{"casks": [{"token": "beta"}]}"#,
674            0,
675        );
676        let now = 1_000_000;
677        let _ = info_cask("alpha", &cache, now, env.fs.as_ref(), &runner).unwrap();
678        let _ = info_cask("beta", &cache, now, env.fs.as_ref(), &runner).unwrap();
679        assert!(env.fs.exists(&cache.join("alpha.json")));
680        assert!(env.fs.exists(&cache.join("beta.json")));
681
682        invalidate_all_cache(&cache, env.fs.as_ref());
683        assert!(!env.fs.exists(&cache.join("alpha.json")));
684        assert!(!env.fs.exists(&cache.join("beta.json")));
685    }
686
687    #[test]
688    fn cache_path_sanitizes_token() {
689        // Hypothetical token containing path separators — brew tokens
690        // don't actually do this today but the sanitization keeps the
691        // cache key total. Path component check is what matters: no
692        // entry should resolve outside the cache dir via `..`.
693        use std::path::Component;
694        let cache = Path::new("/tmp/brew-cache");
695        let p = cache_path_for(cache, "evil/../token");
696        assert!(p.starts_with(cache));
697        let escapes = p.components().any(|c| matches!(c, Component::ParentDir));
698        assert!(
699            !escapes,
700            "sanitized path must not contain a ParentDir component: {}",
701            p.display()
702        );
703    }
704}