Skip to main content

toride_ssh_config/
cache.rs

1//! Process-wide memoization of SSH config reads and parses, keyed by the
2//! file's `(mtime, len)` stamp; an unmeasurable mtime bypasses the cache.
3
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::sync::LazyLock;
8use std::sync::Mutex;
9use std::time::UNIX_EPOCH;
10
11use crate::ast;
12
13struct CachedConfig {
14    stamp: FileStamp,
15    content: Arc<String>,
16    ast: Option<Arc<ast::ConfigAst>>,
17}
18
19#[derive(Clone, Copy, PartialEq, Eq, Debug)]
20struct FileStamp {
21    mtime_ns: u128,
22    len: u64,
23}
24
25fn stamp_of(metadata: &std::fs::Metadata) -> Option<FileStamp> {
26    let mtime_ns = metadata
27        .modified()
28        .ok()?
29        .duration_since(UNIX_EPOCH)
30        .ok()?
31        .as_nanos();
32    Some(FileStamp {
33        mtime_ns,
34        len: metadata.len(),
35    })
36}
37
38static CONFIG_CACHE: LazyLock<Mutex<HashMap<PathBuf, CachedConfig>>> =
39    LazyLock::new(|| Mutex::new(HashMap::new()));
40
41enum Probe {
42    Parsed(Arc<ast::ConfigAst>),
43    Content(Arc<String>),
44}
45
46/// Read and parse the config at `path`, memoized on its `(mtime, len)` stamp.
47///
48/// # Errors
49/// As `std::fs::metadata` / `std::fs::read_to_string` on the underlying file.
50///
51/// # Panics
52/// Only if the cache mutex is poisoned.
53pub fn load_cached_ast(path: &Path) -> std::io::Result<Arc<ast::ConfigAst>> {
54    let metadata = std::fs::metadata(path)?;
55    let Some(stamp) = stamp_of(&metadata) else {
56        return Ok(Arc::new(read_and_parse(path)?));
57    };
58
59    let probe = {
60        let cache = CONFIG_CACHE.lock().expect("config cache mutex poisoned");
61        cache
62            .get(path)
63            .filter(|entry| entry.stamp == stamp)
64            .map(|entry| match &entry.ast {
65                Some(ast) => Probe::Parsed(Arc::clone(ast)),
66                None => Probe::Content(Arc::clone(&entry.content)),
67            })
68    };
69    match probe {
70        Some(Probe::Parsed(ast)) => return Ok(ast),
71        Some(Probe::Content(content)) => {
72            let ast = Arc::new(ast::parse(&content));
73            if let Some(entry) = CONFIG_CACHE
74                .lock()
75                .expect("config cache mutex poisoned")
76                .get_mut(path)
77                && entry.stamp == stamp
78            {
79                entry.ast = Some(Arc::clone(&ast));
80            }
81            return Ok(ast);
82        }
83        None => {}
84    }
85
86    let content = Arc::new(std::fs::read_to_string(path)?);
87    let ast = Arc::new(ast::parse(&content));
88    CONFIG_CACHE
89        .lock()
90        .expect("config cache mutex poisoned")
91        .insert(
92            path.to_path_buf(),
93            CachedConfig {
94                stamp,
95                content: Arc::clone(&content),
96                ast: Some(Arc::clone(&ast)),
97            },
98        );
99    Ok(ast)
100}
101
102/// Read the config at `path` as raw content, memoized on its `(mtime, len)`
103/// stamp.
104///
105/// # Errors
106/// As `std::fs::metadata` / `std::fs::read_to_string` on the underlying file.
107///
108/// # Panics
109/// Only if the cache mutex is poisoned.
110pub fn load_cached_content(path: &Path) -> std::io::Result<Arc<String>> {
111    let metadata = std::fs::metadata(path)?;
112    let Some(stamp) = stamp_of(&metadata) else {
113        return Ok(Arc::new(std::fs::read_to_string(path)?));
114    };
115
116    {
117        let cache = CONFIG_CACHE.lock().expect("config cache mutex poisoned");
118        if let Some(entry) = cache.get(path)
119            && entry.stamp == stamp
120        {
121            return Ok(Arc::clone(&entry.content));
122        }
123    }
124
125    let content = Arc::new(std::fs::read_to_string(path)?);
126    CONFIG_CACHE
127        .lock()
128        .expect("config cache mutex poisoned")
129        .insert(
130            path.to_path_buf(),
131            CachedConfig {
132                stamp,
133                content: Arc::clone(&content),
134                ast: None,
135            },
136        );
137    Ok(content)
138}
139
140fn read_and_parse(path: &Path) -> std::io::Result<ast::ConfigAst> {
141    let content = std::fs::read_to_string(path)?;
142    Ok(ast::parse(&content))
143}
144
145#[cfg(test)]
146pub(crate) fn clear_cache_for_tests(path: &Path) {
147    CONFIG_CACHE
148        .lock()
149        .expect("config cache mutex poisoned")
150        .remove(path);
151}
152
153#[cfg(test)]
154#[path = "cache.test.rs"]
155mod tests;