Skip to main content

ready_set/
cache.rs

1//! Plugin metadata cache.
2//!
3//! See `docs/contracts/cache.md`. Keyed by `(canonical_path, size_bytes,
4//! head4k_sha256)`. TTL 86400s.
5
6use std::collections::BTreeMap;
7use std::fs::File;
8use std::io::Read;
9use std::path::{Path, PathBuf};
10
11use directories::ProjectDirs;
12use ready_set_sdk::fs as sdk_fs;
13use ready_set_sdk::manifest::Manifest;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use time::OffsetDateTime;
17use time::format_description::well_known::Rfc3339;
18
19const SCHEMA_VERSION: u32 = 1;
20const TTL_SECONDS: i64 = 86_400;
21const HEAD_BYTES: usize = 4096;
22
23/// On-disk shape of the cache file.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct PluginCache {
26    /// Always `1` at v0.1.0.
27    pub schema_version: u32,
28    /// Cache entries keyed by `<canonical_path>:<size>:<head4k_sha256>`.
29    pub entries: BTreeMap<String, CacheEntry>,
30}
31
32/// Cached manifest for one plugin binary.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct CacheEntry {
35    /// Cached manifest payload.
36    pub manifest: Manifest,
37    /// RFC3339 UTC timestamp when this entry was inserted.
38    pub cached_at: String,
39}
40
41impl Default for PluginCache {
42    fn default() -> Self {
43        Self {
44            schema_version: SCHEMA_VERSION,
45            entries: BTreeMap::new(),
46        }
47    }
48}
49
50impl PluginCache {
51    /// Resolve the cache file path for the current platform.
52    #[must_use]
53    pub fn default_path() -> Option<PathBuf> {
54        ProjectDirs::from("dev", "ready-set", "ready-set")
55            .map(|d| d.cache_dir().join("plugins.json"))
56    }
57
58    /// Load the cache from `path`. Missing or corrupt files yield an empty
59    /// cache rather than an error.
60    #[must_use]
61    pub fn load(path: &Path) -> Self {
62        let Ok(raw) = std::fs::read(path) else {
63            return Self::default();
64        };
65        let Ok(parsed) = serde_json::from_slice::<Self>(&raw) else {
66            return Self::default();
67        };
68        if parsed.schema_version != SCHEMA_VERSION {
69            return Self::default();
70        }
71        parsed
72    }
73
74    /// Save the cache atomically.
75    ///
76    /// # Errors
77    ///
78    /// Returns the underlying I/O failure from the atomic-write helper.
79    pub fn save(&self, path: &Path) -> ready_set_sdk::Result<()> {
80        let bytes = serde_json::to_vec(self)?;
81        sdk_fs::atomic_write(path, &bytes)
82    }
83
84    /// Look up a cached manifest. Returns `None` if missing or expired.
85    #[must_use]
86    pub fn get(&self, key: &CacheKey) -> Option<&Manifest> {
87        let entry = self.entries.get(&key.encode())?;
88        let cached = OffsetDateTime::parse(&entry.cached_at, &Rfc3339).ok()?;
89        let age = (OffsetDateTime::now_utc() - cached).whole_seconds();
90        if (0..TTL_SECONDS).contains(&age) {
91            Some(&entry.manifest)
92        } else {
93            None
94        }
95    }
96
97    /// Insert a cache entry, replacing any prior entry with the same key.
98    ///
99    /// # Errors
100    ///
101    /// Returns [`ready_set_sdk::Error::Other`] if the timestamp cannot be
102    /// formatted.
103    pub fn insert(&mut self, key: &CacheKey, manifest: Manifest) -> ready_set_sdk::Result<()> {
104        let cached_at = OffsetDateTime::now_utc()
105            .format(&Rfc3339)
106            .map_err(|e| ready_set_sdk::Error::other(format!("rfc3339 format: {e}")))?;
107        self.entries.insert(
108            key.encode(),
109            CacheEntry {
110                manifest,
111                cached_at,
112            },
113        );
114        Ok(())
115    }
116}
117
118/// Composite cache key.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct CacheKey {
121    /// Canonical, symlink-resolved binary path.
122    pub canonical_path: PathBuf,
123    /// Size of the binary in bytes.
124    pub size_bytes: u64,
125    /// SHA-256 of the first 4096 bytes (or the whole file if shorter).
126    pub head4k_sha256: String,
127}
128
129impl CacheKey {
130    /// Construct a cache key by reading metadata + the head of the binary.
131    ///
132    /// # Errors
133    ///
134    /// Forwards I/O errors from canonicalizing the path or reading its head.
135    pub fn for_binary(path: &Path) -> ready_set_sdk::Result<Self> {
136        let canonical_path = std::fs::canonicalize(path)?;
137        let metadata = std::fs::metadata(&canonical_path)?;
138        let size_bytes = metadata.len();
139
140        let mut f = File::open(&canonical_path)?;
141        let mut buf = vec![0_u8; HEAD_BYTES];
142        let read = f.read(&mut buf)?;
143        let mut hasher = Sha256::new();
144        hasher.update(&buf[..read]);
145        let head4k_sha256 = encode_hex_lower(&hasher.finalize());
146
147        Ok(Self {
148            canonical_path,
149            size_bytes,
150            head4k_sha256,
151        })
152    }
153
154    fn encode(&self) -> String {
155        format!(
156            "{}:{}:{}",
157            self.canonical_path.display(),
158            self.size_bytes,
159            self.head4k_sha256
160        )
161    }
162}
163
164fn encode_hex_lower(bytes: &[u8]) -> String {
165    const HEX: &[u8; 16] = b"0123456789abcdef";
166    let mut out = String::with_capacity(bytes.len() * 2);
167    for b in bytes {
168        out.push(HEX[(b >> 4) as usize] as char);
169        out.push(HEX[(b & 0x0f) as usize] as char);
170    }
171    out
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use ready_set_sdk::describe::{Platform, Stability};
178
179    fn fixture_manifest() -> Manifest {
180        Manifest {
181            description: "x".into(),
182            version: "0.1.0".parse().unwrap(),
183            stability: Stability::Stable,
184            min_dispatcher_version: "0.1.0".parse().unwrap(),
185            platforms: vec![Platform::Linux],
186            requires_cargo_workspace: false,
187            capabilities: Vec::new(),
188        }
189    }
190
191    #[test]
192    fn schema_mismatch_yields_empty_cache() {
193        let dir = tempfile::tempdir().unwrap();
194        let path = dir.path().join("plugins.json");
195        std::fs::write(&path, br#"{"schema_version":99,"entries":{}}"#).unwrap();
196        let loaded = PluginCache::load(&path);
197        assert_eq!(loaded.schema_version, SCHEMA_VERSION);
198        assert!(loaded.entries.is_empty());
199    }
200
201    #[test]
202    fn corrupt_file_yields_empty_cache() {
203        let dir = tempfile::tempdir().unwrap();
204        let path = dir.path().join("plugins.json");
205        std::fs::write(&path, b"not json").unwrap();
206        let loaded = PluginCache::load(&path);
207        assert!(loaded.entries.is_empty());
208    }
209
210    #[test]
211    fn round_trip_save_and_load() {
212        let dir = tempfile::tempdir().unwrap();
213        let path = dir.path().join("plugins.json");
214        let bin = dir.path().join("ready-set-foo");
215        std::fs::write(&bin, b"#!/bin/sh\necho hi\n").unwrap();
216        let key = CacheKey::for_binary(&bin).unwrap();
217        let mut cache = PluginCache::default();
218        cache.insert(&key, fixture_manifest()).unwrap();
219        cache.save(&path).unwrap();
220        let loaded = PluginCache::load(&path);
221        assert!(loaded.get(&key).is_some());
222    }
223}