Skip to main content

xdg_thumbnail/
inspection.rs

1// SPDX-FileCopyrightText: 2026 KIM Hyunjae
2// SPDX-License-Identifier: MPL-2.0
3
4use 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/// Original URI identity parsed from a cache entry.
20#[derive(Clone, Debug, Eq, Hash, PartialEq)]
21#[non_exhaustive]
22pub enum OriginalUriIdentity {
23    /// Absolute personal-cache URI identity.
24    Personal(PersonalOriginalUri),
25    /// Shared repository relative URI identity.
26    Shared(SharedRelativeOriginalUri),
27}
28
29/// Validation confidence and validity for policy-neutral cache inspection.
30#[derive(Clone, Debug, Eq, PartialEq)]
31#[non_exhaustive]
32pub enum CacheEntryInspectionOutcome {
33    /// Inspection parsed the entry but did not validate it against an original.
34    Unvalidated,
35    /// The entry is invalid for inspection or cache-management use.
36    Invalid(Vec<CacheEntryProblem>),
37}
38
39/// Whether access time was preserved while inspecting an entry.
40#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
41#[non_exhaustive]
42pub enum AccessTimePreservation {
43    /// Inspection preserved access time.
44    Preserved,
45    /// Inspection may have updated access time.
46    NotPreserved,
47    /// No content read was needed.
48    NotNeeded,
49    /// Access-time preservation is unsupported.
50    Unsupported,
51}
52
53/// Timestamp facts captured for a thumbnail cache entry.
54#[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    /// Returns the thumbnail file access time when available.
63    #[must_use]
64    pub const fn accessed_at(&self) -> Option<SystemTime> {
65        self.accessed_at
66    }
67
68    /// Returns the thumbnail file modification time when available.
69    #[must_use]
70    pub const fn modified_at(&self) -> Option<SystemTime> {
71        self.modified_at
72    }
73
74    /// Returns whether metadata inspection preserved access time.
75    #[must_use]
76    pub const fn access_time_preserved_during_inspection(&self) -> AccessTimePreservation {
77        self.access_time_preserved_during_inspection
78    }
79}
80
81/// Policy for nonstandard filenames discovered during personal-cache inspection.
82#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
83#[non_exhaustive]
84pub enum NonstandardEntryPolicy {
85    /// Skip nonstandard filenames during inspection.
86    #[default]
87    Exclude,
88    /// Include nonstandard filenames as invalid inspection entries.
89    Include,
90}
91
92/// Policy-neutral inspection facts for a cache entry.
93#[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    /// Returns the validation or inspection outcome.
106    #[must_use]
107    pub const fn outcome(&self) -> &CacheEntryInspectionOutcome {
108        &self.outcome
109    }
110
111    /// Returns the original URI parsed from metadata when present and valid.
112    #[must_use]
113    pub const fn original_uri(&self) -> Option<&OriginalUriIdentity> {
114        self.original_uri.as_ref()
115    }
116
117    /// Returns parsed metadata when the entry was a readable PNG.
118    #[must_use]
119    pub const fn metadata(&self) -> Option<&ThumbnailMetadata> {
120        self.metadata.as_ref()
121    }
122
123    /// Returns timestamp facts.
124    #[must_use]
125    pub const fn timestamps(&self) -> &ThumbnailTimestamps {
126        &self.timestamps
127    }
128
129    /// Returns the cache namespace.
130    #[must_use]
131    pub const fn namespace(&self) -> &CacheNamespace {
132        &self.namespace
133    }
134
135    /// Returns the inspected cache path.
136    #[must_use]
137    pub fn path(&self) -> &Path {
138        &self.path
139    }
140
141    /// Returns a handle that can safely remove this cache entry.
142    #[must_use]
143    pub const fn removal_handle(&self) -> &CacheEntryHandle {
144        &self.handle
145    }
146
147    /// Consumes this inspection and returns its removal handle.
148    #[must_use]
149    pub fn into_handle(self) -> CacheEntryHandle {
150        self.handle
151    }
152
153    /// Splits this inspection into its owned facts and removal handle.
154    #[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/// Owned parts of [`CacheEntryInspection`].
169#[derive(Debug, Eq, PartialEq)]
170#[non_exhaustive]
171pub struct CacheEntryInspectionParts {
172    /// Validation or inspection outcome.
173    pub outcome: CacheEntryInspectionOutcome,
174    /// Original URI parsed from metadata when present and valid.
175    pub original_uri: Option<OriginalUriIdentity>,
176    /// Parsed metadata when the entry was a readable PNG.
177    pub metadata: Option<ThumbnailMetadata>,
178    /// Timestamp facts.
179    pub timestamps: ThumbnailTimestamps,
180    /// Cache namespace.
181    pub namespace: CacheNamespace,
182    /// Inspected cache path.
183    pub path: PathBuf,
184    /// Safe-removal handle for the inspected cache entry.
185    pub handle: CacheEntryHandle,
186}
187
188/// A handle for a discovered cache entry.
189#[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    /// Removes the handled entry after containment and symlink checks.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error when the handled path no longer passes containment or symlink safety checks
205    /// or when filesystem removal fails.
206    pub fn remove(self) -> Result<()> {
207        remove_cache_entry_handle(&self)
208    }
209
210    /// Returns the handled path.
211    #[must_use]
212    pub fn path(&self) -> &Path {
213        &self.path
214    }
215}
216
217impl PersonalCacheRoot {
218    /// Inspects standard successful thumbnail size directories.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error when a selected namespace directory cannot be inspected.
223    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    /// Inspects direct files in immediate real failure-entry namespaces.
244    ///
245    /// # Errors
246    ///
247    /// Returns an error when the failure root or a selected failure namespace cannot be inspected.
248    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, &timestamps).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}