Skip to main content

resopt/
cache.rs

1//! Persistent, content-addressed cache of per-image analysis results.
2//!
3//! An entry is reused only when the source bytes, candidate policy, analysis
4//! options, tool version and codec backend all match. Entries are written to a
5//! temporary directory and renamed into place, every file is hash-checked on
6//! read, and anything unexpected discards the entry instead of being trusted.
7use crate::{
8    AnalysisOptions, ResourceAnalysis,
9    filesystem::{hash, write_new},
10};
11use anyhow::{Context, Result, ensure};
12use serde::{Deserialize, Serialize};
13use std::{
14    fs,
15    path::{Path, PathBuf},
16    sync::OnceLock,
17    time::SystemTime,
18};
19
20/// Bump when the entry layout or any cached measurement changes meaning.
21const CACHE_SCHEMA: u32 = 2;
22const MAX_ENTRY_FILE_BYTES: u64 = 64 * 1024 * 1024;
23/// Default size bound; the oldest entries are pruned after each analysis.
24pub(crate) const DEFAULT_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024;
25
26pub(crate) struct Cache {
27    root: PathBuf,
28}
29
30#[derive(Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32struct Entry {
33    schema: u32,
34    key: String,
35    analysis: ResourceAnalysis,
36    files: Vec<EntryFile>,
37}
38
39#[derive(Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41struct EntryFile {
42    /// Report-relative path with the resource index replaced by `{i}`.
43    template: String,
44    name: String,
45    sha256: String,
46}
47
48/// The options that influence candidate bytes or verdicts. Presentation and
49/// scheduling options (jobs, cache settings, ignore rules) are excluded.
50#[derive(Serialize)]
51struct KeyMaterial<'a> {
52    schema: u32,
53    version: &'a str,
54    backend: &'a str,
55    source_sha256: &'a str,
56    policy: &'a str,
57    qualities: &'a [u8],
58    min_input_bytes: u64,
59    min_savings_bytes: u64,
60    max_alpha_error: u32,
61    min_score: u64,
62    max_pixels: usize,
63    png_level: u8,
64    png_reductions: bool,
65    webp: bool,
66}
67
68/// Codec identity. ImageIO output can change between macOS builds; the bundled
69/// codecs only change with the tool version, which is keyed separately.
70pub(crate) fn backend_id() -> &'static str {
71    static ID: OnceLock<String> = OnceLock::new();
72    ID.get_or_init(|| {
73        if cfg!(target_os = "macos") {
74            let build = std::process::Command::new("/usr/bin/sw_vers")
75                .arg("-buildVersion")
76                .output()
77                .ok()
78                .filter(|o| o.status.success())
79                .and_then(|o| String::from_utf8(o.stdout).ok())
80                .map(|s| s.trim().to_string())
81                .filter(|s| !s.is_empty() && s.len() <= 32)
82                .unwrap_or_else(|| "unknown".into());
83            format!("macos-imageio-{build}")
84        } else {
85            format!("portable-{}", std::env::consts::OS)
86        }
87    })
88}
89
90/// Per-user cache location; `RESOPT_CACHE_DIR` overrides it.
91pub fn default_directory() -> Option<PathBuf> {
92    let var = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty());
93    if let Some(path) = var("RESOPT_CACHE_DIR") {
94        return Some(PathBuf::from(path));
95    }
96    if cfg!(target_os = "macos") {
97        var("HOME").map(|home| PathBuf::from(home).join("Library/Caches/resopt"))
98    } else if cfg!(windows) {
99        var("LOCALAPPDATA").map(|dir| PathBuf::from(dir).join("resopt").join("cache"))
100    } else {
101        var("XDG_CACHE_HOME")
102            .map(PathBuf::from)
103            .or_else(|| var("HOME").map(|home| PathBuf::from(home).join(".cache")))
104            .map(|dir| dir.join("resopt"))
105    }
106}
107
108impl Cache {
109    pub fn open(directory: &Path) -> Result<Self> {
110        let root = directory.join(format!("v{CACHE_SCHEMA}"));
111        fs::create_dir_all(&root)
112            .with_context(|| format!("creating cache directory {}", root.display()))?;
113        let root = fs::canonicalize(root)?;
114        Ok(Self { root })
115    }
116
117    pub fn key(source_sha256: &str, policy: &str, options: &AnalysisOptions) -> Result<String> {
118        Ok(hash(&serde_json::to_vec(&KeyMaterial {
119            schema: CACHE_SCHEMA,
120            version: env!("CARGO_PKG_VERSION"),
121            backend: backend_id(),
122            source_sha256,
123            policy,
124            qualities: &options.qualities,
125            min_input_bytes: options.min_input_bytes,
126            min_savings_bytes: options.min_savings_bytes,
127            max_alpha_error: options.max_alpha_error.to_bits(),
128            min_score: options.min_score.to_bits(),
129            max_pixels: options.max_pixels,
130            png_level: options.png_level,
131            png_reductions: options.png_reductions,
132            webp: options.webp,
133        })?))
134    }
135
136    fn entry_directory(&self, key: &str) -> Result<PathBuf> {
137        ensure!(
138            key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit()),
139            "invalid cache key"
140        );
141        Ok(self.root.join(&key[..2]).join(key))
142    }
143
144    /// Copy a verified entry into `out`, renaming files for `index`. Any
145    /// inconsistency removes the entry and reports a miss.
146    pub fn load(&self, key: &str, out: &Path, index: usize) -> Option<ResourceAnalysis> {
147        let directory = self.entry_directory(key).ok()?;
148        if !directory.is_dir() {
149            return None;
150        }
151        match self.load_verified(&directory, key, out, index) {
152            Ok(analysis) => {
153                // Recency drives pruning.
154                let _ = fs::File::options()
155                    .write(true)
156                    .open(directory.join("entry.json"))
157                    .and_then(|file| file.set_modified(SystemTime::now()));
158                Some(analysis)
159            }
160            Err(_) => {
161                let _ = fs::remove_dir_all(&directory);
162                None
163            }
164        }
165    }
166
167    fn load_verified(
168        &self,
169        directory: &Path,
170        key: &str,
171        out: &Path,
172        index: usize,
173    ) -> Result<ResourceAnalysis> {
174        let manifest = directory.join("entry.json");
175        ensure!(
176            fs::symlink_metadata(&manifest)?.len() <= MAX_ENTRY_FILE_BYTES,
177            "cache manifest too large"
178        );
179        let entry: Entry = serde_json::from_slice(&fs::read(&manifest)?)?;
180        ensure!(
181            entry.schema == CACHE_SCHEMA && entry.key == key,
182            "cache entry mismatch"
183        );
184        let mut staged = Vec::with_capacity(entry.files.len());
185        for file in &entry.files {
186            ensure!(
187                file.name.len() <= 8
188                    && file.name.bytes().all(|b| b.is_ascii_alphanumeric())
189                    && safe_template(&file.template),
190                "unsafe cache file"
191            );
192            let path = directory.join(&file.name);
193            let metadata = fs::symlink_metadata(&path)?;
194            ensure!(
195                metadata.file_type().is_file() && metadata.len() <= MAX_ENTRY_FILE_BYTES,
196                "invalid cache file"
197            );
198            let bytes = fs::read(&path)?;
199            ensure!(hash(&bytes) == file.sha256, "cache file corrupted");
200            staged.push((file.template.replace("{i}", &index.to_string()), bytes));
201        }
202        let mut analysis = entry.analysis;
203        let rename = |path: &mut Option<PathBuf>| -> Result<()> {
204            if let Some(template) = path.as_ref().and_then(|p| p.to_str()) {
205                ensure!(
206                    entry.files.iter().any(|f| f.template == template),
207                    "cache entry references a missing file"
208                );
209                *path = Some(PathBuf::from(template.replace("{i}", &index.to_string())));
210            }
211            Ok(())
212        };
213        rename(&mut analysis.original_preview)?;
214        for candidate in &mut analysis.candidates {
215            rename(&mut candidate.artifact)?;
216            rename(&mut candidate.preview)?;
217        }
218        let mut written = Vec::with_capacity(staged.len());
219        for (relative, bytes) in staged {
220            let path = out.join(relative);
221            if let Err(error) = crate::filesystem::write_artifact(&path, &bytes) {
222                // Leave no partial copy behind: the caller re-analyzes into the
223                // same file names.
224                for path in written {
225                    let _ = fs::remove_file(path);
226                }
227                return Err(error);
228            }
229            written.push(path);
230        }
231        Ok(analysis)
232    }
233
234    /// Store a completed analysis. `analysis` paths must be the report-relative
235    /// names written for `index`; the original artifact is never cached because
236    /// it is a copy of the source.
237    pub fn store(
238        &self,
239        key: &str,
240        analysis: &ResourceAnalysis,
241        out: &Path,
242        index: usize,
243    ) -> Result<()> {
244        let directory = self.entry_directory(key)?;
245        if directory.exists() {
246            return Ok(());
247        }
248        let parent = directory.parent().context("cache entry has no parent")?;
249        fs::create_dir_all(parent)?;
250        let staging = tempfile::Builder::new()
251            .prefix("tmp-")
252            .tempdir_in(&self.root)?;
253        let mut template_analysis = analysis.clone();
254        template_analysis.original_artifact = None;
255        let mut files = Vec::new();
256        let mut add = |path: &mut Option<PathBuf>| -> Result<()> {
257            let Some(relative) = path.clone() else {
258                return Ok(());
259            };
260            let template = template_for(&relative, index)?;
261            let bytes = fs::read(out.join(&relative))?;
262            let name = format!("f{}", files.len());
263            // No fsync: a torn file fails its hash check and discards the entry.
264            crate::filesystem::write_artifact(&staging.path().join(&name), &bytes)?;
265            files.push(EntryFile {
266                template: template.clone(),
267                name,
268                sha256: hash(&bytes),
269            });
270            *path = Some(PathBuf::from(template));
271            Ok(())
272        };
273        add(&mut template_analysis.original_preview)?;
274        for candidate in &mut template_analysis.candidates {
275            add(&mut candidate.artifact)?;
276            add(&mut candidate.preview)?;
277        }
278        let entry = Entry {
279            schema: CACHE_SCHEMA,
280            key: key.to_string(),
281            analysis: template_analysis,
282            files,
283        };
284        // The manifest is written last, so a directory without it is never used.
285        write_new(
286            &staging.path().join("entry.json"),
287            &serde_json::to_vec(&entry)?,
288        )?;
289        let staged = staging.keep();
290        if fs::rename(&staged, &directory).is_err() {
291            // Another process stored the same key first; its entry is equivalent.
292            let _ = fs::remove_dir_all(&staged);
293        }
294        Ok(())
295    }
296
297    /// Remove abandoned staging directories and the oldest entries above `max_bytes`.
298    pub fn prune(&self, max_bytes: u64) -> Result<()> {
299        let mut entries = Vec::new();
300        let mut total = 0_u64;
301        for shard in fs::read_dir(&self.root)? {
302            let shard = shard?.path();
303            let name = shard.file_name().unwrap_or_default().to_string_lossy();
304            if name.starts_with("tmp-") {
305                let stale = fs::metadata(&shard)
306                    .and_then(|m| m.modified())
307                    .ok()
308                    .and_then(|t| t.elapsed().ok())
309                    .is_some_and(|age| age.as_secs() > 3600);
310                if stale {
311                    let _ = fs::remove_dir_all(&shard);
312                }
313                continue;
314            }
315            if !shard.is_dir() {
316                continue;
317            }
318            for entry in fs::read_dir(&shard)? {
319                let entry = entry?.path();
320                let Ok(manifest) = fs::metadata(entry.join("entry.json")) else {
321                    let _ = fs::remove_dir_all(&entry);
322                    continue;
323                };
324                let bytes: u64 = fs::read_dir(&entry)?
325                    .filter_map(|f| f.ok()?.metadata().ok())
326                    .map(|m| m.len())
327                    .sum();
328                total += bytes;
329                entries.push((
330                    manifest.modified().unwrap_or(SystemTime::UNIX_EPOCH),
331                    bytes,
332                    entry,
333                ));
334            }
335        }
336        entries.sort();
337        for (_, bytes, entry) in entries {
338            if total <= max_bytes {
339                break;
340            }
341            if fs::remove_dir_all(&entry).is_ok() {
342                total = total.saturating_sub(bytes);
343            }
344        }
345        Ok(())
346    }
347}
348
349fn safe_template(template: &str) -> bool {
350    let mut parts = template.split('/');
351    matches!(parts.next(), Some("candidates" | "previews"))
352        && parts.next().is_some_and(|name| {
353            name.starts_with("{i}-")
354                && name[3..]
355                    .bytes()
356                    .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.'))
357                && !name.contains("..")
358        })
359        && parts.next().is_none()
360}
361
362fn template_for(relative: &Path, index: usize) -> Result<String> {
363    let text = relative
364        .to_str()
365        .context("non-UTF8 artifact path")?
366        .replace('\\', "/");
367    let (folder, name) = text
368        .split_once('/')
369        .context("artifact path has no folder")?;
370    let rest = name
371        .strip_prefix(&format!("{index}-"))
372        .context("artifact name does not start with its resource index")?;
373    let template = format!("{folder}/{{i}}-{rest}");
374    ensure!(safe_template(&template), "unsupported artifact name {text}");
375    Ok(template)
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::{ImageCandidate, Resource};
382
383    fn analysis(index: usize) -> ResourceAnalysis {
384        ResourceAnalysis {
385            resource: Resource::for_tests("a/picture.png", "png"),
386            sha256: Some("0".repeat(64)),
387            image: None,
388            status: "candidates_available".into(),
389            issues: vec![],
390            candidates: vec![ImageCandidate {
391                format: "png".into(),
392                quality: None,
393                lossy: false,
394                bytes: 3,
395                savings_bytes: 5,
396                valid: true,
397                rejection: None,
398                difference: None,
399                artifact: Some(format!("candidates/{index}-png-0.png").into()),
400                preview: Some(format!("previews/{index}-png-0.png").into()),
401                sha256: Some(hash(b"art")),
402                warnings: vec![],
403                notes: vec![],
404            }],
405            smallest_candidate: Some(0),
406            original_preview: Some(format!("previews/{index}-original.png").into()),
407            original_artifact: Some(format!("originals/{index}.png").into()),
408            fingerprint: None,
409            media: None,
410        }
411    }
412
413    fn report_directory(index: usize) -> tempfile::TempDir {
414        let out = tempfile::tempdir().unwrap();
415        for folder in ["candidates", "previews", "originals"] {
416            fs::create_dir(out.path().join(folder)).unwrap();
417        }
418        fs::write(
419            out.path().join(format!("candidates/{index}-png-0.png")),
420            b"art",
421        )
422        .unwrap();
423        fs::write(
424            out.path().join(format!("previews/{index}-png-0.png")),
425            b"pre",
426        )
427        .unwrap();
428        fs::write(
429            out.path().join(format!("previews/{index}-original.png")),
430            b"orig-preview",
431        )
432        .unwrap();
433        out
434    }
435
436    fn empty_report() -> tempfile::TempDir {
437        let out = tempfile::tempdir().unwrap();
438        for folder in ["candidates", "previews", "originals"] {
439            fs::create_dir(out.path().join(folder)).unwrap();
440        }
441        out
442    }
443
444    #[test]
445    fn round_trip_renames_files_for_the_new_resource_index() {
446        let home = tempfile::tempdir().unwrap();
447        let cache = Cache::open(home.path()).unwrap();
448        let key = Cache::key(&"1".repeat(64), "loose", &AnalysisOptions::default()).unwrap();
449        let first = report_directory(4);
450        cache.store(&key, &analysis(4), first.path(), 4).unwrap();
451        let second = empty_report();
452        let loaded = cache.load(&key, second.path(), 9).unwrap();
453        assert_eq!(
454            loaded.candidates[0].artifact.as_deref(),
455            Some(Path::new("candidates/9-png-0.png"))
456        );
457        assert_eq!(loaded.original_artifact, None);
458        assert_eq!(
459            fs::read(second.path().join("candidates/9-png-0.png")).unwrap(),
460            b"art"
461        );
462        assert_eq!(
463            fs::read(second.path().join("previews/9-original.png")).unwrap(),
464            b"orig-preview"
465        );
466    }
467
468    #[test]
469    fn key_changes_with_source_policy_and_relevant_options_only() {
470        let options = AnalysisOptions::default();
471        let base = Cache::key(&"1".repeat(64), "loose", &options).unwrap();
472        assert_ne!(
473            base,
474            Cache::key(&"2".repeat(64), "loose", &options).unwrap()
475        );
476        assert_ne!(
477            base,
478            Cache::key(&"1".repeat(64), "catalog", &options).unwrap()
479        );
480        for changed in [
481            AnalysisOptions {
482                qualities: vec![85],
483                ..options.clone()
484            },
485            AnalysisOptions {
486                webp: true,
487                ..options.clone()
488            },
489            AnalysisOptions {
490                min_score: 50.0,
491                ..options.clone()
492            },
493            AnalysisOptions {
494                png_reductions: true,
495                ..options.clone()
496            },
497        ] {
498            assert_ne!(
499                base,
500                Cache::key(&"1".repeat(64), "loose", &changed).unwrap()
501            );
502        }
503        let scheduling_only = AnalysisOptions {
504            jobs: 1,
505            include_ignored: true,
506            ..options.clone()
507        };
508        assert_eq!(
509            base,
510            Cache::key(&"1".repeat(64), "loose", &scheduling_only).unwrap()
511        );
512    }
513
514    #[test]
515    fn corrupted_truncated_and_interrupted_entries_are_discarded() {
516        let home = tempfile::tempdir().unwrap();
517        let cache = Cache::open(home.path()).unwrap();
518        let key = Cache::key(&"1".repeat(64), "loose", &AnalysisOptions::default()).unwrap();
519        let source = report_directory(0);
520        let directory = cache.entry_directory(&key).unwrap();
521
522        // Corrupted payload.
523        cache.store(&key, &analysis(0), source.path(), 0).unwrap();
524        fs::write(directory.join("f1"), b"tampered").unwrap();
525        let out = empty_report();
526        assert!(cache.load(&key, out.path(), 0).is_none());
527        assert!(!directory.exists());
528        assert!(
529            fs::read_dir(out.path().join("candidates"))
530                .unwrap()
531                .next()
532                .is_none()
533        );
534
535        // Interrupted write: files without a manifest.
536        fs::create_dir_all(&directory).unwrap();
537        fs::write(directory.join("f0"), b"art").unwrap();
538        assert!(cache.load(&key, empty_report().path(), 0).is_none());
539        assert!(!directory.exists());
540
541        // Truncated manifest and a manifest for another key.
542        cache.store(&key, &analysis(0), source.path(), 0).unwrap();
543        fs::write(directory.join("entry.json"), b"{\"schema\":1").unwrap();
544        assert!(cache.load(&key, empty_report().path(), 0).is_none());
545        cache.store(&key, &analysis(0), source.path(), 0).unwrap();
546        let other = Cache::key(&"3".repeat(64), "loose", &AnalysisOptions::default()).unwrap();
547        let other_directory = cache.entry_directory(&other).unwrap();
548        fs::create_dir_all(other_directory.parent().unwrap()).unwrap();
549        fs::rename(&directory, &other_directory).unwrap();
550        assert!(cache.load(&other, empty_report().path(), 0).is_none());
551    }
552
553    #[test]
554    fn traversal_templates_are_never_written() {
555        assert!(safe_template("candidates/{i}-png-0.png"));
556        for template in [
557            "candidates/../{i}-x.png",
558            "../{i}-x.png",
559            "originals/{i}-x.png",
560            "candidates/{i}-../x",
561            "candidates/x.png",
562            "candidates/{i}-a/b.png",
563        ] {
564            assert!(!safe_template(template), "{template}");
565        }
566    }
567
568    #[test]
569    fn prune_removes_oldest_entries_and_stale_staging() {
570        let home = tempfile::tempdir().unwrap();
571        let cache = Cache::open(home.path()).unwrap();
572        let source = report_directory(0);
573        let keys: Vec<_> = (0..3)
574            .map(|i| {
575                Cache::key(
576                    &i.to_string().repeat(64),
577                    "loose",
578                    &AnalysisOptions::default(),
579                )
580                .unwrap()
581            })
582            .collect();
583        for (age, key) in keys.iter().enumerate() {
584            cache.store(key, &analysis(0), source.path(), 0).unwrap();
585            let manifest = cache.entry_directory(key).unwrap().join("entry.json");
586            fs::File::options()
587                .write(true)
588                .open(manifest)
589                .unwrap()
590                .set_modified(
591                    SystemTime::now() - std::time::Duration::from_secs(1000 - age as u64 * 100),
592                )
593                .unwrap();
594        }
595        let entry_bytes: u64 = fs::read_dir(cache.entry_directory(&keys[0]).unwrap())
596            .unwrap()
597            .map(|f| f.unwrap().metadata().unwrap().len())
598            .sum();
599        cache.prune(entry_bytes * 2).unwrap();
600        assert!(!cache.entry_directory(&keys[0]).unwrap().exists());
601        assert!(cache.entry_directory(&keys[1]).unwrap().exists());
602        assert!(cache.entry_directory(&keys[2]).unwrap().exists());
603    }
604}