1use std::ffi::OsStr;
5use std::fs::{self, File};
6use std::io::Read;
7use std::path::{Path, PathBuf};
8use std::time::SystemTime;
9
10use std::os::unix::fs::MetadataExt;
11
12use crate::{
13 CacheEntryProblem, CacheNamespace, FailureNamespace, ParsedThumbnailPng, PersonalCacheRoot,
14 PersonalOriginalUri, Result, SharedRelativeOriginalUri, ThumbnailError, ThumbnailMetadata,
15 ThumbnailMetadataKey, ThumbnailMetadataProblemKind, ThumbnailSize, metadata_problem,
16 push_problem, validate_mime_type,
17};
18
19#[derive(Clone, Debug, Eq, Hash, PartialEq)]
21#[non_exhaustive]
22pub enum OriginalUriIdentity {
23 Personal(PersonalOriginalUri),
25 Shared(SharedRelativeOriginalUri),
27}
28
29#[derive(Clone, Debug, Eq, PartialEq)]
31#[non_exhaustive]
32pub enum CacheEntryInspectionOutcome {
33 Unvalidated,
35 Invalid(Vec<CacheEntryProblem>),
37}
38
39#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
41#[non_exhaustive]
42pub enum AccessTimePreservation {
43 Preserved,
45 NotPreserved,
47 NotNeeded,
49 Unsupported,
51}
52
53#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct ThumbnailTimestamps {
56 accessed_at: Option<SystemTime>,
57 modified_at: Option<SystemTime>,
58 access_time_preserved_during_inspection: AccessTimePreservation,
59}
60
61impl ThumbnailTimestamps {
62 #[must_use]
64 pub const fn accessed_at(&self) -> Option<SystemTime> {
65 self.accessed_at
66 }
67
68 #[must_use]
70 pub const fn modified_at(&self) -> Option<SystemTime> {
71 self.modified_at
72 }
73
74 #[must_use]
76 pub const fn access_time_preserved_during_inspection(&self) -> AccessTimePreservation {
77 self.access_time_preserved_during_inspection
78 }
79}
80
81#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
83#[non_exhaustive]
84pub enum NonstandardEntryPolicy {
85 #[default]
87 Exclude,
88 Include,
90}
91
92#[derive(Debug, Eq, PartialEq)]
94pub struct CacheEntryInspection {
95 outcome: CacheEntryInspectionOutcome,
96 original_uri: Option<OriginalUriIdentity>,
97 metadata: Option<ThumbnailMetadata>,
98 timestamps: ThumbnailTimestamps,
99 namespace: CacheNamespace,
100 path: PathBuf,
101 handle: CacheEntryHandle,
102}
103
104impl CacheEntryInspection {
105 #[must_use]
107 pub const fn outcome(&self) -> &CacheEntryInspectionOutcome {
108 &self.outcome
109 }
110
111 #[must_use]
113 pub const fn original_uri(&self) -> Option<&OriginalUriIdentity> {
114 self.original_uri.as_ref()
115 }
116
117 #[must_use]
119 pub const fn metadata(&self) -> Option<&ThumbnailMetadata> {
120 self.metadata.as_ref()
121 }
122
123 #[must_use]
125 pub const fn timestamps(&self) -> &ThumbnailTimestamps {
126 &self.timestamps
127 }
128
129 #[must_use]
131 pub const fn namespace(&self) -> &CacheNamespace {
132 &self.namespace
133 }
134
135 #[must_use]
137 pub fn path(&self) -> &Path {
138 &self.path
139 }
140
141 #[must_use]
143 pub const fn removal_handle(&self) -> &CacheEntryHandle {
144 &self.handle
145 }
146
147 #[must_use]
149 pub fn into_handle(self) -> CacheEntryHandle {
150 self.handle
151 }
152
153 #[must_use]
155 pub fn into_parts(self) -> CacheEntryInspectionParts {
156 CacheEntryInspectionParts {
157 outcome: self.outcome,
158 original_uri: self.original_uri,
159 metadata: self.metadata,
160 timestamps: self.timestamps,
161 namespace: self.namespace,
162 path: self.path,
163 handle: self.handle,
164 }
165 }
166}
167
168#[derive(Debug, Eq, PartialEq)]
170#[non_exhaustive]
171pub struct CacheEntryInspectionParts {
172 pub outcome: CacheEntryInspectionOutcome,
174 pub original_uri: Option<OriginalUriIdentity>,
176 pub metadata: Option<ThumbnailMetadata>,
178 pub timestamps: ThumbnailTimestamps,
180 pub namespace: CacheNamespace,
182 pub path: PathBuf,
184 pub handle: CacheEntryHandle,
186}
187
188#[derive(Debug, Eq, PartialEq)]
190pub struct CacheEntryHandle {
191 cache_dir: PathBuf,
192 path: PathBuf,
193}
194
195impl CacheEntryHandle {
196 pub(crate) fn new(cache_dir: PathBuf, path: PathBuf) -> Self {
197 Self { cache_dir, path }
198 }
199
200 pub fn remove(self) -> Result<()> {
207 remove_cache_entry_handle(&self)
208 }
209
210 #[must_use]
212 pub fn path(&self) -> &Path {
213 &self.path
214 }
215}
216
217impl PersonalCacheRoot {
218 pub fn inspect_thumbnails(
224 &self,
225 sizes: &[ThumbnailSize],
226 nonstandard_entry_policy: NonstandardEntryPolicy,
227 ) -> Result<Vec<CacheEntryInspection>> {
228 let mut inspections = Vec::new();
229 for &size in sizes {
230 let namespace = CacheNamespace::Size(size);
231 let dir = self.as_path().join(size.directory_name());
232 inspect_namespace_dir(
233 &dir,
234 namespace,
235 nonstandard_entry_policy,
236 Some(size),
237 &mut inspections,
238 )?;
239 }
240 Ok(inspections)
241 }
242
243 pub fn inspect_failure_entries(
249 &self,
250 nonstandard_entry_policy: NonstandardEntryPolicy,
251 ) -> Result<Vec<CacheEntryInspection>> {
252 let fail_root = self.as_path().join("fail");
253 let mut inspections = Vec::new();
254 let entries = match fs::read_dir(&fail_root) {
255 Ok(entries) => entries,
256 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(inspections),
257 Err(source) => {
258 return Err(ThumbnailError::io(
259 "read failure thumbnail directory",
260 Some(fail_root.clone()),
261 source,
262 ));
263 }
264 };
265
266 for entry in entries {
267 let entry = entry.map_err(|source| {
268 ThumbnailError::io(
269 "read failure namespace directory entry",
270 Some(fail_root.clone()),
271 source,
272 )
273 })?;
274 let file_type = entry.file_type().map_err(|source| {
275 ThumbnailError::io(
276 "read failure namespace file type",
277 Some(entry.path()),
278 source,
279 )
280 })?;
281 if file_type.is_symlink() || !file_type.is_dir() {
282 continue;
283 }
284 let Some(namespace_name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
285 continue;
286 };
287 let Ok(namespace) = FailureNamespace::new(namespace_name) else {
288 continue;
289 };
290 inspect_namespace_dir(
291 &entry.path(),
292 CacheNamespace::Failure(namespace),
293 nonstandard_entry_policy,
294 None,
295 &mut inspections,
296 )?;
297 }
298 Ok(inspections)
299 }
300}
301
302fn inspect_namespace_dir(
303 dir: &Path,
304 namespace: CacheNamespace,
305 nonstandard_entry_policy: NonstandardEntryPolicy,
306 successful_size: Option<ThumbnailSize>,
307 inspections: &mut Vec<CacheEntryInspection>,
308) -> Result<()> {
309 let metadata = match fs::symlink_metadata(dir) {
310 Ok(metadata) => metadata,
311 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
312 Err(source) => {
313 return Err(ThumbnailError::io(
314 "inspect thumbnail namespace directory",
315 Some(dir.to_owned()),
316 source,
317 ));
318 }
319 };
320 if metadata.file_type().is_symlink() || !metadata.is_dir() {
321 return Ok(());
322 }
323
324 let entries = match fs::read_dir(dir) {
325 Ok(entries) => entries,
326 Err(source) => {
327 return Err(ThumbnailError::io(
328 "read thumbnail namespace directory",
329 Some(dir.to_owned()),
330 source,
331 ));
332 }
333 };
334
335 for entry in entries {
336 let entry = entry.map_err(|source| {
337 ThumbnailError::io(
338 "read thumbnail directory entry",
339 Some(dir.to_owned()),
340 source,
341 )
342 })?;
343 let path = entry.path();
344 let filename = entry.file_name();
345 let standard = filename
346 .to_str()
347 .is_some_and(is_standard_thumbnail_filename);
348 if !standard && nonstandard_entry_policy == NonstandardEntryPolicy::Exclude {
349 continue;
350 }
351
352 let handle = CacheEntryHandle {
353 cache_dir: dir.to_owned(),
354 path: path.clone(),
355 };
356 if standard {
357 inspections.push(inspect_cache_entry(
358 path,
359 namespace.clone(),
360 handle,
361 successful_size,
362 ));
363 } else {
364 let timestamps = thumbnail_timestamps(&path, AccessTimePreservation::NotNeeded);
365 inspections.push(CacheEntryInspection {
366 outcome: CacheEntryInspectionOutcome::Invalid(vec![
367 CacheEntryProblem::NonstandardFilename,
368 ]),
369 original_uri: None,
370 metadata: None,
371 timestamps,
372 namespace: namespace.clone(),
373 path,
374 handle,
375 });
376 }
377 }
378 Ok(())
379}
380
381fn inspect_cache_entry(
382 path: PathBuf,
383 namespace: CacheNamespace,
384 handle: CacheEntryHandle,
385 successful_size: Option<ThumbnailSize>,
386) -> CacheEntryInspection {
387 let mut timestamps = thumbnail_timestamps(&path, AccessTimePreservation::NotNeeded);
388 let metadata = match fs::symlink_metadata(&path) {
389 Ok(metadata) => metadata,
390 Err(_) => {
391 return CacheEntryInspection {
392 outcome: CacheEntryInspectionOutcome::Invalid(vec![
393 CacheEntryProblem::UnreadableEntry,
394 ]),
395 original_uri: None,
396 metadata: None,
397 timestamps,
398 namespace,
399 path,
400 handle,
401 };
402 }
403 };
404
405 if metadata.file_type().is_symlink() || !metadata.is_file() {
406 return CacheEntryInspection {
407 outcome: CacheEntryInspectionOutcome::Invalid(vec![CacheEntryProblem::UnreadableEntry]),
408 original_uri: None,
409 metadata: None,
410 timestamps,
411 namespace,
412 path,
413 handle,
414 };
415 }
416
417 let (read_result, preservation) = read_thumbnail_for_inspection(&path);
418 timestamps = thumbnail_timestamps_from_metadata(&metadata, preservation);
419 let bytes = match read_result {
420 Ok(bytes) => bytes,
421 Err(_) => {
422 return CacheEntryInspection {
423 outcome: CacheEntryInspectionOutcome::Invalid(vec![
424 CacheEntryProblem::UnreadableEntry,
425 ]),
426 original_uri: None,
427 metadata: None,
428 timestamps,
429 namespace,
430 path,
431 handle,
432 };
433 }
434 };
435
436 let parsed = match ParsedThumbnailPng::parse(&bytes) {
437 Ok(parsed) => parsed,
438 Err(ThumbnailError::ResourceLimitExceeded { .. }) => {
439 return CacheEntryInspection {
440 outcome: CacheEntryInspectionOutcome::Invalid(vec![
441 CacheEntryProblem::ResourceLimitExceeded,
442 ]),
443 original_uri: None,
444 metadata: None,
445 timestamps,
446 namespace,
447 path,
448 handle,
449 };
450 }
451 Err(_) => {
452 return CacheEntryInspection {
453 outcome: CacheEntryInspectionOutcome::Invalid(vec![
454 CacheEntryProblem::InvalidPngStructure,
455 ]),
456 original_uri: None,
457 metadata: None,
458 timestamps,
459 namespace,
460 path,
461 handle,
462 };
463 }
464 };
465
466 let mut problems =
467 successful_size.map_or_else(Vec::new, |size| parsed.conformance_problems(size));
468 let original_uri = inspect_required_metadata(&mut problems, parsed.metadata());
469 if let Some(OriginalUriIdentity::Personal(uri)) = &original_uri {
470 inspect_filename_uri_match(&mut problems, &path, uri);
471 }
472 let outcome = if problems.is_empty() {
473 CacheEntryInspectionOutcome::Unvalidated
474 } else {
475 CacheEntryInspectionOutcome::Invalid(problems)
476 };
477
478 CacheEntryInspection {
479 outcome,
480 original_uri,
481 metadata: Some(parsed.into_metadata()),
482 timestamps,
483 namespace,
484 path,
485 handle,
486 }
487}
488
489fn inspect_required_metadata(
490 problems: &mut Vec<CacheEntryProblem>,
491 metadata: &ThumbnailMetadata,
492) -> Option<OriginalUriIdentity> {
493 let original_uri = match metadata.thumb_uri() {
494 Some(uri) => match PersonalOriginalUri::from_validated_absolute_uri(uri) {
495 Ok(uri) => Some(OriginalUriIdentity::Personal(uri)),
496 Err(_) => {
497 push_problem(
498 problems,
499 metadata_problem(
500 ThumbnailMetadataKey::Uri,
501 ThumbnailMetadataProblemKind::InvalidSyntax,
502 ),
503 );
504 None
505 }
506 },
507 None => {
508 push_problem(
509 problems,
510 metadata_problem(
511 ThumbnailMetadataKey::Uri,
512 ThumbnailMetadataProblemKind::MissingRequired,
513 ),
514 );
515 None
516 }
517 };
518 match metadata.thumb_mtime_result() {
519 Ok(Some(_)) => {}
520 Ok(None) => push_problem(
521 problems,
522 metadata_problem(
523 ThumbnailMetadataKey::Mtime,
524 ThumbnailMetadataProblemKind::MissingRequired,
525 ),
526 ),
527 Err(_) => push_problem(
528 problems,
529 metadata_problem(
530 ThumbnailMetadataKey::Mtime,
531 ThumbnailMetadataProblemKind::InvalidSyntax,
532 ),
533 ),
534 }
535 if metadata.thumb_size_result().is_err() {
536 push_problem(
537 problems,
538 metadata_problem(
539 ThumbnailMetadataKey::Size,
540 ThumbnailMetadataProblemKind::InvalidSyntax,
541 ),
542 );
543 }
544 if let Some(mime_type) = metadata.thumb_mime_type() {
545 if validate_mime_type(mime_type).is_err() {
546 push_problem(
547 problems,
548 metadata_problem(
549 ThumbnailMetadataKey::MimeType,
550 ThumbnailMetadataProblemKind::InvalidSyntax,
551 ),
552 );
553 }
554 }
555 original_uri
556}
557
558fn inspect_filename_uri_match(
559 problems: &mut Vec<CacheEntryProblem>,
560 path: &Path,
561 uri: &PersonalOriginalUri,
562) {
563 let Some(filename) = path.file_name().and_then(OsStr::to_str) else {
564 push_problem(problems, CacheEntryProblem::UriFilenameMismatch);
565 return;
566 };
567 if filename != uri.thumbnail_file_name() {
568 push_problem(problems, CacheEntryProblem::UriFilenameMismatch);
569 }
570}
571
572pub(crate) fn read_thumbnail_for_inspection(
573 path: &Path,
574) -> (std::io::Result<Vec<u8>>, AccessTimePreservation) {
575 read_thumbnail_for_inspection_unix(path)
576}
577
578fn read_thumbnail_for_inspection_unix(
579 path: &Path,
580) -> (std::io::Result<Vec<u8>>, AccessTimePreservation) {
581 #[cfg(any(target_os = "linux", target_os = "fuchsia"))]
582 {
583 let flags = rustix::fs::OFlags::RDONLY
584 | rustix::fs::OFlags::CLOEXEC
585 | rustix::fs::OFlags::NOFOLLOW
586 | rustix::fs::OFlags::NOATIME;
587 if let Ok(bytes) = read_thumbnail_with_flags(path, flags) {
588 return (Ok(bytes), AccessTimePreservation::Preserved);
589 }
590 }
591
592 read_thumbnail_and_restore_timestamps(path)
593}
594
595fn read_thumbnail_with_flags(path: &Path, flags: rustix::fs::OFlags) -> std::io::Result<Vec<u8>> {
596 let fd =
597 rustix::fs::open(path, flags, rustix::fs::Mode::empty()).map_err(std::io::Error::from)?;
598 let mut file = File::from(fd);
599 let mut bytes = Vec::new();
600 file.read_to_end(&mut bytes)?;
601 Ok(bytes)
602}
603
604fn read_thumbnail_and_restore_timestamps(
605 path: &Path,
606) -> (std::io::Result<Vec<u8>>, AccessTimePreservation) {
607 let flags =
608 rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::CLOEXEC | rustix::fs::OFlags::NOFOLLOW;
609 let fd = match rustix::fs::open(path, flags, rustix::fs::Mode::empty()) {
610 Ok(fd) => fd,
611 Err(error) => {
612 return (
613 Err(std::io::Error::from(error)),
614 AccessTimePreservation::Unsupported,
615 );
616 }
617 };
618 let mut file = File::from(fd);
619 let timestamps = match file.metadata() {
620 Ok(metadata) => timestamps_from_unix_metadata(&metadata),
621 Err(error) => return (Err(error), AccessTimePreservation::Unsupported),
622 };
623 let mut bytes = Vec::new();
624 if let Err(error) = file.read_to_end(&mut bytes) {
625 return (Err(error), AccessTimePreservation::Unsupported);
626 }
627
628 let preservation = if rustix::fs::futimens(&file, ×tamps).is_ok() {
629 AccessTimePreservation::Preserved
630 } else {
631 AccessTimePreservation::NotPreserved
632 };
633 (Ok(bytes), preservation)
634}
635
636fn timestamps_from_unix_metadata(metadata: &fs::Metadata) -> rustix::fs::Timestamps {
637 rustix::fs::Timestamps {
638 last_access: rustix::fs::Timespec {
639 tv_sec: metadata.atime(),
640 tv_nsec: metadata.atime_nsec() as _,
641 },
642 last_modification: rustix::fs::Timespec {
643 tv_sec: metadata.mtime(),
644 tv_nsec: metadata.mtime_nsec() as _,
645 },
646 }
647}
648
649pub(crate) fn thumbnail_timestamps(
650 path: &Path,
651 preservation: AccessTimePreservation,
652) -> ThumbnailTimestamps {
653 let (accessed_at, modified_at) = fs::symlink_metadata(path)
654 .map_or((None, None), |metadata| timestamps_from_metadata(&metadata));
655 ThumbnailTimestamps {
656 accessed_at,
657 modified_at,
658 access_time_preserved_during_inspection: preservation,
659 }
660}
661
662pub(crate) fn thumbnail_timestamps_from_metadata(
663 metadata: &fs::Metadata,
664 preservation: AccessTimePreservation,
665) -> ThumbnailTimestamps {
666 let (accessed_at, modified_at) = timestamps_from_metadata(metadata);
667 ThumbnailTimestamps {
668 accessed_at,
669 modified_at,
670 access_time_preserved_during_inspection: preservation,
671 }
672}
673
674fn timestamps_from_metadata(metadata: &fs::Metadata) -> (Option<SystemTime>, Option<SystemTime>) {
675 (metadata.accessed().ok(), metadata.modified().ok())
676}
677
678fn is_standard_thumbnail_filename(name: &str) -> bool {
679 let Some(stem) = name.strip_suffix(".png") else {
680 return false;
681 };
682 stem.len() == 32
683 && stem
684 .bytes()
685 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
686}
687
688fn remove_cache_entry_handle(handle: &CacheEntryHandle) -> Result<()> {
689 let filename = handle
690 .path
691 .file_name()
692 .ok_or_else(|| ThumbnailError::unsafe_removal("entry has no filename"))?;
693 let filename_path = Path::new(filename);
694 if filename_path.components().count() != 1
695 || filename == OsStr::new(".")
696 || filename == OsStr::new("..")
697 || handle.path.parent() != Some(handle.cache_dir.as_path())
698 {
699 return Err(ThumbnailError::unsafe_removal(
700 "entry is not a direct child of its cache directory",
701 ));
702 }
703
704 let dir = rustix::fs::open(
705 &handle.cache_dir,
706 rustix::fs::OFlags::RDONLY
707 | rustix::fs::OFlags::CLOEXEC
708 | rustix::fs::OFlags::DIRECTORY
709 | rustix::fs::OFlags::NOFOLLOW,
710 rustix::fs::Mode::empty(),
711 )
712 .map_err(|source| {
713 ThumbnailError::io(
714 "open cache directory before removal",
715 Some(handle.cache_dir.clone()),
716 std::io::Error::from(source),
717 )
718 })?;
719
720 let stat = rustix::fs::statat(&dir, filename, rustix::fs::AtFlags::SYMLINK_NOFOLLOW).map_err(
721 |source| {
722 ThumbnailError::io(
723 "inspect cache entry before removal",
724 Some(handle.path.clone()),
725 std::io::Error::from(source),
726 )
727 },
728 )?;
729 let file_type = rustix::fs::FileType::from_raw_mode(stat.st_mode);
730 if file_type.is_symlink() {
731 return Err(ThumbnailError::unsafe_removal("entry is a symlink"));
732 }
733 if !file_type.is_file() {
734 return Err(ThumbnailError::unsafe_removal(
735 "entry is not a regular file",
736 ));
737 }
738
739 rustix::fs::unlinkat(&dir, filename, rustix::fs::AtFlags::empty()).map_err(|source| {
740 ThumbnailError::io(
741 "remove cache entry",
742 Some(handle.path.clone()),
743 std::io::Error::from(source),
744 )
745 })
746}