1use 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
20const CACHE_SCHEMA: u32 = 3;
22const MAX_ENTRY_FILE_BYTES: u64 = 64 * 1024 * 1024;
23pub(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 template: String,
44 name: String,
45 sha256: String,
46}
47
48#[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
68pub(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
90pub 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 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 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 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 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 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 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 let _ = fs::remove_dir_all(&staged);
293 }
294 Ok(())
295 }
296
297 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 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 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 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}