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