Skip to main content

xdg_thumbnail/
cache.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, Write};
7use std::path::{Path, PathBuf};
8
9use std::os::unix::ffi::OsStrExt;
10use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
11
12use crate::inspection::{
13    CacheEntryInspection, NonstandardEntryPolicy, read_thumbnail_for_inspection,
14    thumbnail_timestamps, thumbnail_timestamps_from_metadata,
15};
16use crate::{
17    AccessTimePreservation, CacheDirectoryProblem, CacheEntryHandle, CacheEntryProblem,
18    CacheNamespace, CachePathProblem, CacheRootProblem, FailureNamespace, OwnedRawThumbnailImage,
19    ParsedThumbnailPng, PersonalValidationOutcome, RawThumbnailImage,
20    ReadablePersonalOriginalIdentity, Result, SharedRelativeOriginalUri, SharedRepositoryContext,
21    SharedValidationOutcome, ThumbnailError, ThumbnailMetadata, ThumbnailSize, ThumbnailTimestamps,
22    UnixMtimeSeconds, decode_validated_thumbnail_png_to_rgba8,
23    downscaled_validated_thumbnail_png_to_rgba8, encode_rgba_png, metadata_problem,
24    normalized_personal_thumbnail_from_cache_png, normalized_personal_thumbnail_png,
25    normalized_personal_thumbnail_raw_png, push_problem, thumbnail_metadata_pairs,
26    validate_personal_thumbnail, validate_shared_thumbnail,
27};
28use crate::{PersonalOriginalIdentity, PersonalOriginalUri};
29use crate::{ThumbnailMetadataKey, ThumbnailMetadataProblemKind};
30
31/// Root directory of the personal thumbnail cache, usually `$XDG_CACHE_HOME/thumbnails`.
32#[derive(Clone, Debug, Eq, Hash, PartialEq)]
33pub struct PersonalCacheRoot {
34    path: PathBuf,
35}
36
37impl PersonalCacheRoot {
38    /// Creates a cache root from an already resolved absolute thumbnail root path.
39    ///
40    /// # Errors
41    ///
42    /// Returns an error when `path` is not absolute.
43    pub fn new(path: impl AsRef<Path>) -> Result<Self> {
44        let path = path.as_ref();
45        if !path.is_absolute() {
46            return Err(ThumbnailError::InvalidCacheRoot {
47                path: path.to_owned(),
48                problem: CacheRootProblem::NotAbsolute,
49            });
50        }
51        Ok(Self {
52            path: path.to_owned(),
53        })
54    }
55
56    /// Resolves the personal thumbnail cache root from the process environment.
57    ///
58    /// # Errors
59    ///
60    /// Returns an error when neither an absolute `XDG_CACHE_HOME` nor an absolute `HOME` fallback
61    /// is available.
62    pub fn resolve_from_env() -> Result<Self> {
63        let xdg_cache_home = std::env::var_os("XDG_CACHE_HOME");
64        let home = std::env::var_os("HOME");
65        Self::resolve_from_values(xdg_cache_home.as_deref(), home.as_deref())
66    }
67
68    /// Resolves the personal thumbnail cache root from supplied XDG values.
69    ///
70    /// Relative, unset, and blank `XDG_CACHE_HOME` values are ignored. `HOME`
71    /// must be absolute when fallback is needed.
72    ///
73    /// # Errors
74    ///
75    /// Returns an error when the resolved thumbnail root would not be absolute.
76    pub fn resolve_from_values(
77        xdg_cache_home: Option<&OsStr>,
78        home: Option<&OsStr>,
79    ) -> Result<Self> {
80        if let Some(cache_home) = xdg_cache_home {
81            if !cache_home.as_bytes().is_empty() {
82                let path = PathBuf::from(cache_home);
83                if path.is_absolute() {
84                    return Self::new(path.join("thumbnails"));
85                }
86            }
87        }
88
89        let Some(home) = home else {
90            return Err(ThumbnailError::cache_root_unavailable(
91                "HOME is required when XDG_CACHE_HOME is unset, blank, or relative",
92            ));
93        };
94        if home.as_bytes().is_empty() {
95            return Err(ThumbnailError::cache_root_unavailable(
96                "HOME is required when XDG_CACHE_HOME is unset, blank, or relative",
97            ));
98        }
99        let home = PathBuf::from(home);
100        if !home.is_absolute() {
101            return Err(ThumbnailError::cache_root_unavailable(
102                "HOME must be absolute",
103            ));
104        }
105        Self::new(home.join(".cache").join("thumbnails"))
106    }
107
108    /// Returns the resolved thumbnail root path.
109    #[must_use]
110    pub fn as_path(&self) -> &Path {
111        &self.path
112    }
113
114    /// Computes the personal-cache path for an accepted URI and namespace without reading it.
115    #[must_use]
116    pub fn cache_entry_path(
117        &self,
118        uri: &PersonalOriginalUri,
119        namespace: &CacheNamespace,
120    ) -> PathBuf {
121        namespace.join_under(&self.path, &uri.thumbnail_file_name())
122    }
123
124    /// Returns a safe-removal handle for a computed personal-cache entry path.
125    ///
126    /// The handle uses the same pure path calculation as [`Self::cache_entry_path`] and does not
127    /// check whether the entry currently exists. Removal still performs containment, no-follow, and
128    /// regular-file checks at deletion time.
129    #[must_use]
130    pub fn cache_entry_handle(
131        &self,
132        uri: &PersonalOriginalUri,
133        namespace: &CacheNamespace,
134    ) -> CacheEntryHandle {
135        let cache_dir = match namespace {
136            CacheNamespace::Size(size) => self.path.join(size.directory_name()),
137            CacheNamespace::Failure(namespace) => self.path.join("fail").join(namespace.as_str()),
138        };
139        CacheEntryHandle::new(cache_dir, self.cache_entry_path(uri, namespace))
140    }
141
142    /// Returns a validated personal-cache path for integrations that must pass a filename.
143    ///
144    /// The original identity must have already been confirmed readable. The candidate PNG is
145    /// opened and validated before this method returns. Callers that reopen the returned path
146    /// accept that another process may replace it after validation.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error for unexpected filesystem I/O while reading the candidate or for PNG
151    /// metadata parse failures after validation succeeds.
152    pub fn lookup_thumbnail_path(
153        &self,
154        original: &ReadablePersonalOriginalIdentity,
155        size: ThumbnailSize,
156    ) -> Result<PersonalThumbnailLookup<ThumbnailPathLookupEntry>> {
157        match self.lookup_thumbnail_entry(original, size)? {
158            PersonalThumbnailLookup::Valid(entry) => {
159                Ok(PersonalThumbnailLookup::Valid(ThumbnailPathLookupEntry {
160                    path: entry.path,
161                    metadata: entry.metadata,
162                }))
163            }
164            PersonalThumbnailLookup::Missing => Ok(PersonalThumbnailLookup::Missing),
165            PersonalThumbnailLookup::Invalid(problems) => {
166                Ok(PersonalThumbnailLookup::Invalid(problems))
167            }
168        }
169    }
170
171    /// Returns exact validated PNG bytes from the personal thumbnail cache.
172    ///
173    /// The original identity must have already been confirmed readable.
174    ///
175    /// # Errors
176    ///
177    /// Returns an error for unexpected filesystem I/O while reading the candidate or for PNG
178    /// metadata parse failures after validation succeeds.
179    pub fn lookup_thumbnail_png_bytes(
180        &self,
181        original: &ReadablePersonalOriginalIdentity,
182        size: ThumbnailSize,
183    ) -> Result<PersonalThumbnailLookup<ThumbnailPngBytesLookupEntry>> {
184        match self.lookup_thumbnail_entry(original, size)? {
185            PersonalThumbnailLookup::Valid(entry) => Ok(PersonalThumbnailLookup::Valid(
186                ThumbnailPngBytesLookupEntry {
187                    path: entry.path,
188                    bytes: entry.bytes,
189                    metadata: entry.metadata,
190                },
191            )),
192            PersonalThumbnailLookup::Missing => Ok(PersonalThumbnailLookup::Missing),
193            PersonalThumbnailLookup::Invalid(problems) => {
194                Ok(PersonalThumbnailLookup::Invalid(problems))
195            }
196        }
197    }
198
199    /// Returns decoded tightly packed RGBA8 pixels from the personal thumbnail cache.
200    ///
201    /// The original identity must have already been confirmed readable. The returned pixels are
202    /// row-major `[red, green, blue, alpha]` bytes with straight alpha and `stride == width * 4`.
203    ///
204    /// # Errors
205    ///
206    /// Returns an error for unexpected filesystem I/O while reading the candidate or for PNG
207    /// decoding failures after validation succeeds.
208    pub fn lookup_thumbnail_rgba8(
209        &self,
210        original: &ReadablePersonalOriginalIdentity,
211        size: ThumbnailSize,
212    ) -> Result<PersonalThumbnailLookup<ThumbnailRgba8LookupEntry>> {
213        match self.lookup_thumbnail_entry(original, size)? {
214            PersonalThumbnailLookup::Valid(entry) => Ok(PersonalThumbnailLookup::Valid(
215                rgba8_lookup_entry_from_parts(entry.path, &entry.bytes, entry.metadata)?,
216            )),
217            PersonalThumbnailLookup::Missing => Ok(PersonalThumbnailLookup::Missing),
218            PersonalThumbnailLookup::Invalid(problems) => {
219                Ok(PersonalThumbnailLookup::Invalid(problems))
220            }
221        }
222    }
223
224    /// Returns decoded RGBA8 display pixels, falling back to larger personal-cache namespaces.
225    ///
226    /// The exact namespace is checked first. If it is missing, larger namespaces are checked in
227    /// ascending order. The first non-missing candidate controls the result.
228    ///
229    /// # Errors
230    ///
231    /// Returns an error for unexpected filesystem I/O while reading the candidate or for PNG
232    /// decoding failures after validation succeeds.
233    pub fn lookup_display_thumbnail_rgba8(
234        &self,
235        original: &ReadablePersonalOriginalIdentity,
236        size: ThumbnailSize,
237    ) -> Result<PersonalThumbnailLookup<DisplayThumbnailRgba8LookupEntry>> {
238        for source_size in display_candidate_sizes(size) {
239            match self.lookup_thumbnail_entry(original, source_size)? {
240                PersonalThumbnailLookup::Valid(entry) => {
241                    return Ok(PersonalThumbnailLookup::Valid(
242                        display_rgba8_lookup_entry_from_parts(
243                            entry.path,
244                            &entry.bytes,
245                            entry.metadata,
246                            size,
247                            source_size,
248                        )?,
249                    ));
250                }
251                PersonalThumbnailLookup::Missing => {}
252                PersonalThumbnailLookup::Invalid(problems) => {
253                    return Ok(PersonalThumbnailLookup::Invalid(problems));
254                }
255            }
256        }
257        Ok(PersonalThumbnailLookup::Missing)
258    }
259
260    /// Normalizes rendered PNG data, atomically installs a personal-cache thumbnail, and returns its path.
261    ///
262    /// # Errors
263    ///
264    /// Returns an error when rendered PNG normalization fails, final thumbnail validation fails,
265    /// cache directories are unavailable or insecure, or atomic installation fails.
266    pub fn install_thumbnail_returning_path(
267        &self,
268        original: &ReadablePersonalOriginalIdentity,
269        size: ThumbnailSize,
270        rendered_png: &[u8],
271    ) -> Result<InstalledThumbnailPath> {
272        let (path, _) = self.install_thumbnail_entry(original, size, rendered_png)?;
273        Ok(InstalledThumbnailPath { path })
274    }
275
276    /// Normalizes rendered PNG data, atomically installs a personal-cache thumbnail, and returns final PNG bytes.
277    ///
278    /// # Errors
279    ///
280    /// Returns an error when rendered PNG normalization fails, final thumbnail validation fails,
281    /// cache directories are unavailable or insecure, or atomic installation fails.
282    pub fn install_thumbnail_returning_png_bytes(
283        &self,
284        original: &ReadablePersonalOriginalIdentity,
285        size: ThumbnailSize,
286        rendered_png: &[u8],
287    ) -> Result<InstalledThumbnailPngBytes> {
288        let (path, bytes) = self.install_thumbnail_entry(original, size, rendered_png)?;
289        Ok(InstalledThumbnailPngBytes { path, bytes })
290    }
291
292    /// Normalizes raw pixel data, atomically installs a personal-cache thumbnail, and returns its path.
293    ///
294    /// # Errors
295    ///
296    /// Returns an error when raw conversion or normalization fails, final thumbnail validation
297    /// fails, cache directories are unavailable or insecure, or atomic installation fails.
298    pub fn install_raw_thumbnail_returning_path(
299        &self,
300        original: &ReadablePersonalOriginalIdentity,
301        size: ThumbnailSize,
302        image: RawThumbnailImage<'_>,
303    ) -> Result<InstalledThumbnailPath> {
304        let (path, _) = self.install_thumbnail_raw_entry(original, size, image)?;
305        Ok(InstalledThumbnailPath { path })
306    }
307
308    /// Normalizes raw pixel data, atomically installs a personal-cache thumbnail, and returns final PNG bytes.
309    ///
310    /// # Errors
311    ///
312    /// Returns an error when raw conversion or normalization fails, final thumbnail validation
313    /// fails, cache directories are unavailable or insecure, or atomic installation fails.
314    pub fn install_raw_thumbnail_returning_png_bytes(
315        &self,
316        original: &ReadablePersonalOriginalIdentity,
317        size: ThumbnailSize,
318        image: RawThumbnailImage<'_>,
319    ) -> Result<InstalledThumbnailPngBytes> {
320        let (path, bytes) = self.install_thumbnail_raw_entry(original, size, image)?;
321        Ok(InstalledThumbnailPngBytes { path, bytes })
322    }
323
324    /// Materializes the requested personal-cache namespace from an exact or larger personal entry.
325    ///
326    /// # Errors
327    ///
328    /// Returns an error when final PNG normalization, validation, or atomic installation fails.
329    pub fn materialize_thumbnail_from_larger_cache_returning_path(
330        &self,
331        original: &ReadablePersonalOriginalIdentity,
332        size: ThumbnailSize,
333    ) -> Result<PersonalThumbnailLookup<MaterializedThumbnailPath>> {
334        match self.materialize_thumbnail_from_larger_cache_entry(original, size)? {
335            PersonalThumbnailLookup::Valid(entry) => {
336                Ok(PersonalThumbnailLookup::Valid(MaterializedThumbnailPath {
337                    target_path: entry.target_path,
338                    source_path: entry.source_path,
339                    requested_size: entry.requested_size,
340                    source_size: entry.source_size,
341                    written: entry.written,
342                }))
343            }
344            PersonalThumbnailLookup::Missing => Ok(PersonalThumbnailLookup::Missing),
345            PersonalThumbnailLookup::Invalid(problems) => {
346                Ok(PersonalThumbnailLookup::Invalid(problems))
347            }
348        }
349    }
350
351    /// Materializes the requested personal-cache namespace and returns final target PNG bytes.
352    ///
353    /// # Errors
354    ///
355    /// Returns an error when final PNG normalization, validation, or atomic installation fails.
356    pub fn materialize_thumbnail_from_larger_cache_returning_png_bytes(
357        &self,
358        original: &ReadablePersonalOriginalIdentity,
359        size: ThumbnailSize,
360    ) -> Result<PersonalThumbnailLookup<MaterializedThumbnailPngBytes>> {
361        match self.materialize_thumbnail_from_larger_cache_entry(original, size)? {
362            PersonalThumbnailLookup::Valid(entry) => Ok(PersonalThumbnailLookup::Valid(
363                MaterializedThumbnailPngBytes {
364                    target_path: entry.target_path,
365                    source_path: entry.source_path,
366                    requested_size: entry.requested_size,
367                    source_size: entry.source_size,
368                    written: entry.written,
369                    bytes: entry.bytes,
370                },
371            )),
372            PersonalThumbnailLookup::Missing => Ok(PersonalThumbnailLookup::Missing),
373            PersonalThumbnailLookup::Invalid(problems) => {
374                Ok(PersonalThumbnailLookup::Invalid(problems))
375            }
376        }
377    }
378
379    /// Materializes a shared thumbnail into this personal cache and returns the target path.
380    ///
381    /// Shared repositories are read-only; this method writes only to the receiver personal cache.
382    ///
383    /// # Errors
384    ///
385    /// Returns an error when shared facts cannot produce required personal metadata, final PNG
386    /// normalization fails, or atomic installation fails.
387    pub fn materialize_shared_thumbnail_returning_path(
388        &self,
389        shared: &SharedRepositoryContext,
390        original_facts: SharedOriginalFacts,
391        size: ThumbnailSize,
392    ) -> Result<SharedThumbnailLookup<MaterializedThumbnailPath>> {
393        match self.materialize_shared_thumbnail_entry(shared, original_facts, size)? {
394            SharedThumbnailLookup::FullyVerified(entry) => Ok(
395                SharedThumbnailLookup::FullyVerified(MaterializedThumbnailPath {
396                    target_path: entry.target_path,
397                    source_path: entry.source_path,
398                    requested_size: entry.requested_size,
399                    source_size: entry.source_size,
400                    written: entry.written,
401                }),
402            ),
403            SharedThumbnailLookup::MetadataIncomplete(entry) => Ok(
404                SharedThumbnailLookup::MetadataIncomplete(MaterializedThumbnailPath {
405                    target_path: entry.target_path,
406                    source_path: entry.source_path,
407                    requested_size: entry.requested_size,
408                    source_size: entry.source_size,
409                    written: entry.written,
410                }),
411            ),
412            SharedThumbnailLookup::Missing => Ok(SharedThumbnailLookup::Missing),
413            SharedThumbnailLookup::Invalid(problems) => {
414                Ok(SharedThumbnailLookup::Invalid(problems))
415            }
416            SharedThumbnailLookup::Unverifiable(problems) => {
417                Ok(SharedThumbnailLookup::Unverifiable(problems))
418            }
419        }
420    }
421
422    /// Materializes a shared thumbnail into this personal cache and returns final target PNG bytes.
423    ///
424    /// Shared repositories are read-only; this method writes only to the receiver personal cache.
425    ///
426    /// # Errors
427    ///
428    /// Returns an error when shared facts cannot produce required personal metadata, final PNG
429    /// normalization fails, or atomic installation fails.
430    pub fn materialize_shared_thumbnail_returning_png_bytes(
431        &self,
432        shared: &SharedRepositoryContext,
433        original_facts: SharedOriginalFacts,
434        size: ThumbnailSize,
435    ) -> Result<SharedThumbnailLookup<MaterializedThumbnailPngBytes>> {
436        match self.materialize_shared_thumbnail_entry(shared, original_facts, size)? {
437            SharedThumbnailLookup::FullyVerified(entry) => Ok(
438                SharedThumbnailLookup::FullyVerified(MaterializedThumbnailPngBytes {
439                    target_path: entry.target_path,
440                    source_path: entry.source_path,
441                    requested_size: entry.requested_size,
442                    source_size: entry.source_size,
443                    written: entry.written,
444                    bytes: entry.bytes,
445                }),
446            ),
447            SharedThumbnailLookup::MetadataIncomplete(entry) => Ok(
448                SharedThumbnailLookup::MetadataIncomplete(MaterializedThumbnailPngBytes {
449                    target_path: entry.target_path,
450                    source_path: entry.source_path,
451                    requested_size: entry.requested_size,
452                    source_size: entry.source_size,
453                    written: entry.written,
454                    bytes: entry.bytes,
455                }),
456            ),
457            SharedThumbnailLookup::Missing => Ok(SharedThumbnailLookup::Missing),
458            SharedThumbnailLookup::Invalid(problems) => {
459                Ok(SharedThumbnailLookup::Invalid(problems))
460            }
461            SharedThumbnailLookup::Unverifiable(problems) => {
462                Ok(SharedThumbnailLookup::Unverifiable(problems))
463            }
464        }
465    }
466
467    fn install_thumbnail_entry(
468        &self,
469        original: &ReadablePersonalOriginalIdentity,
470        size: ThumbnailSize,
471        rendered_png: &[u8],
472    ) -> Result<(PathBuf, Vec<u8>)> {
473        let namespace = CacheNamespace::Size(size);
474        let path = self.cache_entry_path(original.identity().uri(), &namespace);
475        let bytes = normalized_personal_thumbnail_png(rendered_png, original.identity(), size)?;
476        self.write_personal_entry(&path, &namespace, &bytes)?;
477        Ok((path, bytes))
478    }
479
480    fn install_thumbnail_raw_entry(
481        &self,
482        original: &ReadablePersonalOriginalIdentity,
483        size: ThumbnailSize,
484        image: RawThumbnailImage<'_>,
485    ) -> Result<(PathBuf, Vec<u8>)> {
486        let namespace = CacheNamespace::Size(size);
487        let path = self.cache_entry_path(original.identity().uri(), &namespace);
488        let bytes = normalized_personal_thumbnail_raw_png(image, original.identity(), size)?;
489        self.write_personal_entry(&path, &namespace, &bytes)?;
490        Ok((path, bytes))
491    }
492
493    fn materialize_thumbnail_from_larger_cache_entry(
494        &self,
495        original: &ReadablePersonalOriginalIdentity,
496        size: ThumbnailSize,
497    ) -> Result<PersonalThumbnailLookup<MaterializedPersonalEntry>> {
498        let target_path =
499            self.cache_entry_path(original.identity().uri(), &CacheNamespace::Size(size));
500        for source_size in display_candidate_sizes(size) {
501            match self.lookup_thumbnail_entry(original, source_size)? {
502                PersonalThumbnailLookup::Valid(entry) if source_size == size => {
503                    return Ok(PersonalThumbnailLookup::Valid(MaterializedPersonalEntry {
504                        target_path,
505                        source_path: entry.path,
506                        requested_size: size,
507                        source_size,
508                        written: false,
509                        bytes: entry.bytes,
510                    }));
511                }
512                PersonalThumbnailLookup::Valid(entry) => {
513                    let bytes = normalized_personal_thumbnail_from_cache_png(
514                        &entry.bytes,
515                        original.identity(),
516                        size,
517                    )?;
518                    self.write_personal_entry(&target_path, &CacheNamespace::Size(size), &bytes)?;
519                    return Ok(PersonalThumbnailLookup::Valid(MaterializedPersonalEntry {
520                        target_path,
521                        source_path: entry.path,
522                        requested_size: size,
523                        source_size,
524                        written: true,
525                        bytes,
526                    }));
527                }
528                PersonalThumbnailLookup::Missing => {}
529                PersonalThumbnailLookup::Invalid(problems) => {
530                    return Ok(PersonalThumbnailLookup::Invalid(problems));
531                }
532            }
533        }
534        Ok(PersonalThumbnailLookup::Missing)
535    }
536
537    fn materialize_shared_thumbnail_entry(
538        &self,
539        shared: &SharedRepositoryContext,
540        original_facts: SharedOriginalFacts,
541        size: ThumbnailSize,
542    ) -> Result<SharedThumbnailLookup<MaterializedPersonalEntry>> {
543        let original = personal_identity_from_shared_facts(shared, original_facts)?;
544        let target_path = self.cache_entry_path(original.uri(), &CacheNamespace::Size(size));
545        for source_size in display_candidate_sizes(size) {
546            match shared.lookup_thumbnail_entry(source_size, original_facts)? {
547                SharedThumbnailLookup::FullyVerified(entry) => {
548                    let bytes = normalized_personal_thumbnail_from_cache_png(
549                        &entry.bytes,
550                        &original,
551                        size,
552                    )?;
553                    self.write_personal_entry(&target_path, &CacheNamespace::Size(size), &bytes)?;
554                    return Ok(SharedThumbnailLookup::FullyVerified(
555                        MaterializedPersonalEntry {
556                            target_path,
557                            source_path: entry.path,
558                            requested_size: size,
559                            source_size,
560                            written: true,
561                            bytes,
562                        },
563                    ));
564                }
565                SharedThumbnailLookup::MetadataIncomplete(entry) => {
566                    let bytes = normalized_personal_thumbnail_from_cache_png(
567                        &entry.bytes,
568                        &original,
569                        size,
570                    )?;
571                    self.write_personal_entry(&target_path, &CacheNamespace::Size(size), &bytes)?;
572                    return Ok(SharedThumbnailLookup::MetadataIncomplete(
573                        MaterializedPersonalEntry {
574                            target_path,
575                            source_path: entry.path,
576                            requested_size: size,
577                            source_size,
578                            written: true,
579                            bytes,
580                        },
581                    ));
582                }
583                SharedThumbnailLookup::Missing => {}
584                SharedThumbnailLookup::Invalid(problems) => {
585                    return Ok(SharedThumbnailLookup::Invalid(problems));
586                }
587                SharedThumbnailLookup::Unverifiable(problems) => {
588                    return Ok(SharedThumbnailLookup::Unverifiable(problems));
589                }
590            }
591        }
592        Ok(SharedThumbnailLookup::Missing)
593    }
594
595    /// Writes a deterministic 1x1 transparent failure entry and returns its path.
596    ///
597    /// # Errors
598    ///
599    /// Returns an error when failure-entry PNG encoding fails, cache directories are unavailable or
600    /// insecure, or atomic installation fails.
601    pub fn write_failure_entry_returning_path(
602        &self,
603        original: &ReadablePersonalOriginalIdentity,
604        namespace: &FailureNamespace,
605    ) -> Result<InstalledThumbnailPath> {
606        let (path, _) = self.write_failure_entry_returning_png_bytes_inner(original, namespace)?;
607        Ok(InstalledThumbnailPath { path })
608    }
609
610    /// Writes a deterministic 1x1 transparent failure entry and returns final PNG bytes.
611    ///
612    /// # Errors
613    ///
614    /// Returns an error when failure-entry PNG encoding fails, cache directories are unavailable or
615    /// insecure, or atomic installation fails.
616    pub fn write_failure_entry_returning_png_bytes(
617        &self,
618        original: &ReadablePersonalOriginalIdentity,
619        namespace: &FailureNamespace,
620    ) -> Result<InstalledThumbnailPngBytes> {
621        let (path, bytes) =
622            self.write_failure_entry_returning_png_bytes_inner(original, namespace)?;
623        Ok(InstalledThumbnailPngBytes { path, bytes })
624    }
625
626    fn write_failure_entry_returning_png_bytes_inner(
627        &self,
628        original: &ReadablePersonalOriginalIdentity,
629        namespace: &FailureNamespace,
630    ) -> Result<(PathBuf, Vec<u8>)> {
631        let namespace = CacheNamespace::Failure(namespace.clone());
632        let path = self.cache_entry_path(original.identity().uri(), &namespace);
633        let bytes = encode_rgba_png(
634            1,
635            1,
636            &[0, 0, 0, 0],
637            &thumbnail_metadata_pairs(original.identity()),
638        )?;
639        self.write_personal_entry(&path, &namespace, &bytes)?;
640        Ok((path, bytes))
641    }
642
643    fn lookup_thumbnail_entry(
644        &self,
645        original: &ReadablePersonalOriginalIdentity,
646        size: ThumbnailSize,
647    ) -> Result<PersonalThumbnailLookup<ValidatedPersonalEntry>> {
648        let path = self.cache_entry_path(original.identity().uri(), &CacheNamespace::Size(size));
649        let bytes = match read_cache_entry_no_follow(&path, "read thumbnail cache entry")? {
650            CacheEntryRead::Bytes(bytes) => bytes,
651            CacheEntryRead::Missing => return Ok(PersonalThumbnailLookup::Missing),
652            CacheEntryRead::Unreadable => {
653                return Ok(PersonalThumbnailLookup::Invalid(vec![
654                    CacheEntryProblem::UnreadableEntry,
655                ]));
656            }
657        };
658
659        match validate_personal_thumbnail(&bytes, original, size) {
660            PersonalValidationOutcome::FullyVerified => {
661                let parsed = ParsedThumbnailPng::parse(&bytes)?;
662                Ok(PersonalThumbnailLookup::Valid(ValidatedPersonalEntry {
663                    path,
664                    bytes,
665                    metadata: parsed.into_metadata(),
666                }))
667            }
668            PersonalValidationOutcome::Invalid(problems) => {
669                Ok(PersonalThumbnailLookup::Invalid(problems))
670            }
671        }
672    }
673
674    fn write_personal_entry(
675        &self,
676        path: &Path,
677        namespace: &CacheNamespace,
678        bytes: &[u8],
679    ) -> Result<()> {
680        self.ensure_namespace_dir(namespace)?;
681        let parent = path
682            .parent()
683            .ok_or_else(|| ThumbnailError::InvalidCachePath {
684                path: path.to_owned(),
685                problem: CachePathProblem::MissingParentDirectory,
686            })?;
687        let mut temp = tempfile::Builder::new()
688            .prefix(".xdg-thumbnail-")
689            .tempfile_in(parent)
690            .map_err(|source| {
691                ThumbnailError::io(
692                    "create thumbnail temporary file",
693                    Some(parent.to_owned()),
694                    source,
695                )
696            })?;
697        temp.as_file_mut().write_all(bytes).map_err(|source| {
698            ThumbnailError::io(
699                "write thumbnail temporary file",
700                Some(temp.path().to_owned()),
701                source,
702            )
703        })?;
704        temp.as_file_mut()
705            .set_permissions(fs::Permissions::from_mode(0o600))
706            .map_err(|source| {
707                ThumbnailError::io(
708                    "set thumbnail temporary file permissions",
709                    Some(temp.path().to_owned()),
710                    source,
711                )
712            })?;
713        temp.as_file_mut().sync_all().map_err(|source| {
714            ThumbnailError::io(
715                "sync thumbnail temporary file",
716                Some(temp.path().to_owned()),
717                source,
718            )
719        })?;
720        fs::rename(temp.path(), path).map_err(|source| {
721            ThumbnailError::io(
722                "publish thumbnail cache entry",
723                Some(path.to_owned()),
724                source,
725            )
726        })?;
727        Ok(())
728    }
729
730    fn ensure_namespace_dir(&self, namespace: &CacheNamespace) -> Result<()> {
731        ensure_private_directory(&self.path)?;
732        match namespace {
733            CacheNamespace::Size(size) => {
734                ensure_private_directory(&self.path.join(size.directory_name()))
735            }
736            CacheNamespace::Failure(namespace) => {
737                let fail = self.path.join("fail");
738                ensure_private_directory(&fail)?;
739                ensure_private_directory(&fail.join(namespace.as_str()))
740            }
741        }
742    }
743}
744
745impl AsRef<Path> for PersonalCacheRoot {
746    fn as_ref(&self) -> &Path {
747        self.as_path()
748    }
749}
750
751impl SharedRepositoryContext {
752    /// Returns a validated shared-repository path for integrations that must pass a filename.
753    ///
754    /// # Errors
755    ///
756    /// Returns an error for unexpected filesystem I/O while reading the candidate or for PNG
757    /// metadata parse failures after validation succeeds.
758    pub fn lookup_thumbnail_path(
759        &self,
760        original_facts: SharedOriginalFacts,
761        size: ThumbnailSize,
762    ) -> Result<SharedThumbnailLookup<ThumbnailPathLookupEntry>> {
763        match self.lookup_thumbnail_entry(size, original_facts)? {
764            SharedThumbnailLookup::FullyVerified(entry) => Ok(
765                SharedThumbnailLookup::FullyVerified(ThumbnailPathLookupEntry {
766                    path: entry.path,
767                    metadata: entry.metadata,
768                }),
769            ),
770            SharedThumbnailLookup::MetadataIncomplete(entry) => Ok(
771                SharedThumbnailLookup::MetadataIncomplete(ThumbnailPathLookupEntry {
772                    path: entry.path,
773                    metadata: entry.metadata,
774                }),
775            ),
776            SharedThumbnailLookup::Missing => Ok(SharedThumbnailLookup::Missing),
777            SharedThumbnailLookup::Invalid(problems) => {
778                Ok(SharedThumbnailLookup::Invalid(problems))
779            }
780            SharedThumbnailLookup::Unverifiable(problems) => {
781                Ok(SharedThumbnailLookup::Unverifiable(problems))
782            }
783        }
784    }
785
786    /// Returns exact validated PNG bytes from a shared thumbnail repository.
787    ///
788    /// # Errors
789    ///
790    /// Returns an error for unexpected filesystem I/O while reading the candidate or for PNG
791    /// metadata parse failures after validation succeeds.
792    pub fn lookup_thumbnail_png_bytes(
793        &self,
794        original_facts: SharedOriginalFacts,
795        size: ThumbnailSize,
796    ) -> Result<SharedThumbnailLookup<ThumbnailPngBytesLookupEntry>> {
797        match self.lookup_thumbnail_entry(size, original_facts)? {
798            SharedThumbnailLookup::FullyVerified(entry) => Ok(
799                SharedThumbnailLookup::FullyVerified(ThumbnailPngBytesLookupEntry {
800                    path: entry.path,
801                    bytes: entry.bytes,
802                    metadata: entry.metadata,
803                }),
804            ),
805            SharedThumbnailLookup::MetadataIncomplete(entry) => Ok(
806                SharedThumbnailLookup::MetadataIncomplete(ThumbnailPngBytesLookupEntry {
807                    path: entry.path,
808                    bytes: entry.bytes,
809                    metadata: entry.metadata,
810                }),
811            ),
812            SharedThumbnailLookup::Missing => Ok(SharedThumbnailLookup::Missing),
813            SharedThumbnailLookup::Invalid(problems) => {
814                Ok(SharedThumbnailLookup::Invalid(problems))
815            }
816            SharedThumbnailLookup::Unverifiable(problems) => {
817                Ok(SharedThumbnailLookup::Unverifiable(problems))
818            }
819        }
820    }
821
822    /// Returns decoded tightly packed RGBA8 pixels from a shared thumbnail repository.
823    ///
824    /// The returned pixels are row-major `[red, green, blue, alpha]` bytes with straight alpha and
825    /// `stride == width * 4`.
826    ///
827    /// # Errors
828    ///
829    /// Returns an error for unexpected filesystem I/O while reading the candidate or for PNG
830    /// decoding failures after validation succeeds.
831    pub fn lookup_thumbnail_rgba8(
832        &self,
833        original_facts: SharedOriginalFacts,
834        size: ThumbnailSize,
835    ) -> Result<SharedThumbnailLookup<ThumbnailRgba8LookupEntry>> {
836        match self.lookup_thumbnail_entry(size, original_facts)? {
837            SharedThumbnailLookup::FullyVerified(entry) => {
838                Ok(SharedThumbnailLookup::FullyVerified(
839                    rgba8_lookup_entry_from_parts(entry.path, &entry.bytes, entry.metadata)?,
840                ))
841            }
842            SharedThumbnailLookup::MetadataIncomplete(entry) => {
843                Ok(SharedThumbnailLookup::MetadataIncomplete(
844                    rgba8_lookup_entry_from_parts(entry.path, &entry.bytes, entry.metadata)?,
845                ))
846            }
847            SharedThumbnailLookup::Missing => Ok(SharedThumbnailLookup::Missing),
848            SharedThumbnailLookup::Invalid(problems) => {
849                Ok(SharedThumbnailLookup::Invalid(problems))
850            }
851            SharedThumbnailLookup::Unverifiable(problems) => {
852                Ok(SharedThumbnailLookup::Unverifiable(problems))
853            }
854        }
855    }
856
857    /// Returns decoded RGBA8 display pixels, falling back to larger shared-repository namespaces.
858    ///
859    /// The exact namespace is checked first. If it is missing, larger namespaces are checked in
860    /// ascending order. The first non-missing candidate controls the result.
861    ///
862    /// # Errors
863    ///
864    /// Returns an error for unexpected filesystem I/O while reading the candidate or for PNG
865    /// decoding failures after validation succeeds.
866    pub fn lookup_display_thumbnail_rgba8(
867        &self,
868        original_facts: SharedOriginalFacts,
869        size: ThumbnailSize,
870    ) -> Result<SharedThumbnailLookup<DisplayThumbnailRgba8LookupEntry>> {
871        for source_size in display_candidate_sizes(size) {
872            match self.lookup_thumbnail_entry(source_size, original_facts)? {
873                SharedThumbnailLookup::FullyVerified(entry) => {
874                    return Ok(SharedThumbnailLookup::FullyVerified(
875                        display_rgba8_lookup_entry_from_parts(
876                            entry.path,
877                            &entry.bytes,
878                            entry.metadata,
879                            size,
880                            source_size,
881                        )?,
882                    ));
883                }
884                SharedThumbnailLookup::MetadataIncomplete(entry) => {
885                    return Ok(SharedThumbnailLookup::MetadataIncomplete(
886                        display_rgba8_lookup_entry_from_parts(
887                            entry.path,
888                            &entry.bytes,
889                            entry.metadata,
890                            size,
891                            source_size,
892                        )?,
893                    ));
894                }
895                SharedThumbnailLookup::Missing => {}
896                SharedThumbnailLookup::Invalid(problems) => {
897                    return Ok(SharedThumbnailLookup::Invalid(problems));
898                }
899                SharedThumbnailLookup::Unverifiable(problems) => {
900                    return Ok(SharedThumbnailLookup::Unverifiable(problems));
901                }
902            }
903        }
904        Ok(SharedThumbnailLookup::Missing)
905    }
906
907    /// Inspects existing shared-repository thumbnails without exposing removal handles.
908    ///
909    /// # Errors
910    ///
911    /// Returns an error when a selected shared thumbnail cannot be inspected due to unexpected
912    /// filesystem I/O.
913    pub fn inspect_thumbnails(
914        &self,
915        sizes: &[ThumbnailSize],
916        original: SharedOriginalMetadata,
917    ) -> Result<Vec<SharedCacheEntryInspection>> {
918        let mut inspections = Vec::new();
919        for &size in sizes {
920            if let Some(inspection) = self.inspect_thumbnail(size, original)? {
921                inspections.push(inspection);
922            }
923        }
924        Ok(inspections)
925    }
926
927    fn lookup_thumbnail_entry(
928        &self,
929        size: ThumbnailSize,
930        original_facts: SharedOriginalFacts,
931    ) -> Result<SharedThumbnailLookup<ValidatedSharedEntry>> {
932        let path = self.cache_entry_path(size);
933        let bytes = match read_cache_entry_no_follow(&path, "read shared thumbnail cache entry")? {
934            CacheEntryRead::Bytes(bytes) => bytes,
935            CacheEntryRead::Missing => return Ok(SharedThumbnailLookup::Missing),
936            CacheEntryRead::Unreadable => {
937                return Ok(SharedThumbnailLookup::Invalid(vec![
938                    CacheEntryProblem::UnreadableEntry,
939                ]));
940            }
941        };
942
943        match validate_shared_thumbnail(&bytes, self, original_facts.metadata(), size) {
944            SharedValidationOutcome::FullyVerified => {
945                let parsed = ParsedThumbnailPng::parse(&bytes)?;
946                Ok(SharedThumbnailLookup::FullyVerified(ValidatedSharedEntry {
947                    path,
948                    bytes,
949                    metadata: parsed.into_metadata(),
950                }))
951            }
952            SharedValidationOutcome::MetadataIncomplete => {
953                if original_facts.metadata_policy()
954                    == SharedThumbnailMetadataPolicy::RequireComplete
955                {
956                    let parsed = ParsedThumbnailPng::parse(&bytes)?;
957                    Ok(SharedThumbnailLookup::Invalid(
958                        missing_required_shared_metadata_problems(parsed.metadata()),
959                    ))
960                } else {
961                    let parsed = ParsedThumbnailPng::parse(&bytes)?;
962                    Ok(SharedThumbnailLookup::MetadataIncomplete(
963                        ValidatedSharedEntry {
964                            path,
965                            bytes,
966                            metadata: parsed.into_metadata(),
967                        },
968                    ))
969                }
970            }
971            SharedValidationOutcome::Invalid(problems) if only_unverifiable_original(&problems) => {
972                Ok(SharedThumbnailLookup::Unverifiable(problems))
973            }
974            SharedValidationOutcome::Invalid(problems) => {
975                Ok(SharedThumbnailLookup::Invalid(problems))
976            }
977        }
978    }
979
980    fn inspect_thumbnail(
981        &self,
982        size: ThumbnailSize,
983        original: SharedOriginalMetadata,
984    ) -> Result<Option<SharedCacheEntryInspection>> {
985        let path = self.cache_entry_path(size);
986        let metadata = match fs::symlink_metadata(&path) {
987            Ok(metadata) => metadata,
988            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
989            Err(_) => {
990                return Ok(Some(SharedCacheEntryInspection {
991                    outcome: SharedCacheEntryOutcome::Invalid(vec![
992                        CacheEntryProblem::UnreadableEntry,
993                    ]),
994                    shared_uri: self.shared_uri().clone(),
995                    timestamps: thumbnail_timestamps(&path, AccessTimePreservation::NotNeeded),
996                    size,
997                    path,
998                    metadata: None,
999                }));
1000            }
1001        };
1002
1003        if metadata.file_type().is_symlink() || !metadata.is_file() {
1004            return Ok(Some(SharedCacheEntryInspection {
1005                outcome: SharedCacheEntryOutcome::Invalid(vec![CacheEntryProblem::UnreadableEntry]),
1006                shared_uri: self.shared_uri().clone(),
1007                timestamps: thumbnail_timestamps_from_metadata(
1008                    &metadata,
1009                    AccessTimePreservation::NotNeeded,
1010                ),
1011                size,
1012                path,
1013                metadata: None,
1014            }));
1015        }
1016
1017        let (read_result, preservation) = read_thumbnail_for_inspection(&path);
1018        let timestamps = thumbnail_timestamps_from_metadata(&metadata, preservation);
1019        let bytes = match read_result {
1020            Ok(bytes) => bytes,
1021            Err(_) => {
1022                return Ok(Some(SharedCacheEntryInspection {
1023                    outcome: SharedCacheEntryOutcome::Invalid(vec![
1024                        CacheEntryProblem::UnreadableEntry,
1025                    ]),
1026                    shared_uri: self.shared_uri().clone(),
1027                    timestamps,
1028                    size,
1029                    path,
1030                    metadata: None,
1031                }));
1032            }
1033        };
1034
1035        let parsed = ParsedThumbnailPng::parse(&bytes).ok();
1036        let outcome =
1037            shared_cache_entry_outcome(validate_shared_thumbnail(&bytes, self, original, size));
1038        Ok(Some(SharedCacheEntryInspection {
1039            outcome,
1040            shared_uri: self.shared_uri().clone(),
1041            timestamps,
1042            size,
1043            path,
1044            metadata: parsed.map(ParsedThumbnailPng::into_metadata),
1045        }))
1046    }
1047}
1048
1049fn missing_required_shared_metadata_problems(
1050    metadata: &ThumbnailMetadata,
1051) -> Vec<CacheEntryProblem> {
1052    let mut problems = Vec::new();
1053    if metadata.thumb_uri().is_none() {
1054        push_problem(
1055            &mut problems,
1056            metadata_problem(
1057                ThumbnailMetadataKey::Uri,
1058                ThumbnailMetadataProblemKind::MissingRequired,
1059            ),
1060        );
1061    }
1062    if matches!(metadata.thumb_mtime_result(), Ok(None)) {
1063        push_problem(
1064            &mut problems,
1065            metadata_problem(
1066                ThumbnailMetadataKey::Mtime,
1067                ThumbnailMetadataProblemKind::MissingRequired,
1068            ),
1069        );
1070    }
1071    problems
1072}
1073
1074/// Owned personal-cache lookup request for async or runtime-specific adapters.
1075///
1076/// Constructing this request does not perform filesystem I/O. Validation happens only when
1077/// [`Self::lookup_path`], [`Self::lookup_png_bytes`], or [`Self::lookup_rgba8`] is called.
1078#[derive(Clone, Debug, Eq, PartialEq)]
1079pub struct PersonalThumbnailLookupRequest {
1080    root: PersonalCacheRoot,
1081    original: ReadablePersonalOriginalIdentity,
1082    size: ThumbnailSize,
1083}
1084
1085impl PersonalThumbnailLookupRequest {
1086    /// Creates an owned personal-cache lookup request.
1087    #[must_use]
1088    pub fn new(
1089        root: PersonalCacheRoot,
1090        original: ReadablePersonalOriginalIdentity,
1091        size: ThumbnailSize,
1092    ) -> Self {
1093        Self {
1094            root,
1095            original,
1096            size,
1097        }
1098    }
1099
1100    /// Returns a validated personal-cache path for the owned request.
1101    ///
1102    /// # Errors
1103    ///
1104    /// Returns the same errors as [`PersonalCacheRoot::lookup_thumbnail_path`].
1105    pub fn lookup_path(self) -> Result<PersonalThumbnailLookup<ThumbnailPathLookupEntry>> {
1106        let Self {
1107            root,
1108            original,
1109            size,
1110        } = self;
1111        root.lookup_thumbnail_path(&original, size)
1112    }
1113
1114    /// Returns exact validated personal-cache PNG bytes for the owned request.
1115    ///
1116    /// # Errors
1117    ///
1118    /// Returns the same errors as [`PersonalCacheRoot::lookup_thumbnail_png_bytes`].
1119    pub fn lookup_png_bytes(self) -> Result<PersonalThumbnailLookup<ThumbnailPngBytesLookupEntry>> {
1120        let Self {
1121            root,
1122            original,
1123            size,
1124        } = self;
1125        root.lookup_thumbnail_png_bytes(&original, size)
1126    }
1127
1128    /// Returns decoded tightly packed RGBA8 pixels for the owned request.
1129    ///
1130    /// # Errors
1131    ///
1132    /// Returns the same errors as [`PersonalCacheRoot::lookup_thumbnail_rgba8`].
1133    pub fn lookup_rgba8(self) -> Result<PersonalThumbnailLookup<ThumbnailRgba8LookupEntry>> {
1134        let Self {
1135            root,
1136            original,
1137            size,
1138        } = self;
1139        root.lookup_thumbnail_rgba8(&original, size)
1140    }
1141
1142    /// Returns decoded display RGBA8 pixels for the owned request.
1143    ///
1144    /// # Errors
1145    ///
1146    /// Returns the same errors as [`PersonalCacheRoot::lookup_display_thumbnail_rgba8`].
1147    pub fn lookup_display_rgba8(
1148        self,
1149    ) -> Result<PersonalThumbnailLookup<DisplayThumbnailRgba8LookupEntry>> {
1150        let Self {
1151            root,
1152            original,
1153            size,
1154        } = self;
1155        root.lookup_display_thumbnail_rgba8(&original, size)
1156    }
1157
1158    /// Splits this request into its owned parts.
1159    #[must_use]
1160    pub fn into_parts(self) -> PersonalThumbnailLookupRequestParts {
1161        PersonalThumbnailLookupRequestParts {
1162            root: self.root,
1163            original: self.original,
1164            size: self.size,
1165        }
1166    }
1167}
1168
1169/// Owned parts of [`PersonalThumbnailLookupRequest`].
1170#[derive(Clone, Debug, Eq, PartialEq)]
1171#[non_exhaustive]
1172pub struct PersonalThumbnailLookupRequestParts {
1173    /// Personal thumbnail cache root.
1174    pub root: PersonalCacheRoot,
1175    /// Readability-confirmed original identity.
1176    pub original: ReadablePersonalOriginalIdentity,
1177    /// Requested thumbnail size.
1178    pub size: ThumbnailSize,
1179}
1180
1181/// Owned personal-cache fallback materialization request for async or runtime-specific adapters.
1182///
1183/// Constructing this request does not perform filesystem I/O. Validation and materialization
1184/// happen only when [`Self::materialize_path`] or [`Self::materialize_png_bytes`] is called.
1185#[derive(Clone, Debug, Eq, PartialEq)]
1186pub struct PersonalThumbnailMaterializationRequest {
1187    root: PersonalCacheRoot,
1188    original: ReadablePersonalOriginalIdentity,
1189    size: ThumbnailSize,
1190}
1191
1192impl PersonalThumbnailMaterializationRequest {
1193    /// Creates an owned personal-cache materialization request.
1194    #[must_use]
1195    pub fn new(
1196        root: PersonalCacheRoot,
1197        original: ReadablePersonalOriginalIdentity,
1198        size: ThumbnailSize,
1199    ) -> Self {
1200        Self {
1201            root,
1202            original,
1203            size,
1204        }
1205    }
1206
1207    /// Materializes the requested personal namespace and returns its path.
1208    ///
1209    /// # Errors
1210    ///
1211    /// Returns the same errors as
1212    /// [`PersonalCacheRoot::materialize_thumbnail_from_larger_cache_returning_path`].
1213    pub fn materialize_path(self) -> Result<PersonalThumbnailLookup<MaterializedThumbnailPath>> {
1214        let Self {
1215            root,
1216            original,
1217            size,
1218        } = self;
1219        root.materialize_thumbnail_from_larger_cache_returning_path(&original, size)
1220    }
1221
1222    /// Materializes the requested personal namespace and returns final PNG bytes.
1223    ///
1224    /// # Errors
1225    ///
1226    /// Returns the same errors as
1227    /// [`PersonalCacheRoot::materialize_thumbnail_from_larger_cache_returning_png_bytes`].
1228    pub fn materialize_png_bytes(
1229        self,
1230    ) -> Result<PersonalThumbnailLookup<MaterializedThumbnailPngBytes>> {
1231        let Self {
1232            root,
1233            original,
1234            size,
1235        } = self;
1236        root.materialize_thumbnail_from_larger_cache_returning_png_bytes(&original, size)
1237    }
1238
1239    /// Splits this request into its owned parts.
1240    #[must_use]
1241    pub fn into_parts(self) -> PersonalThumbnailMaterializationRequestParts {
1242        PersonalThumbnailMaterializationRequestParts {
1243            root: self.root,
1244            original: self.original,
1245            size: self.size,
1246        }
1247    }
1248}
1249
1250/// Owned parts of [`PersonalThumbnailMaterializationRequest`].
1251#[derive(Clone, Debug, Eq, PartialEq)]
1252#[non_exhaustive]
1253pub struct PersonalThumbnailMaterializationRequestParts {
1254    /// Personal thumbnail cache root.
1255    pub root: PersonalCacheRoot,
1256    /// Readability-confirmed original identity.
1257    pub original: ReadablePersonalOriginalIdentity,
1258    /// Requested thumbnail size.
1259    pub size: ThumbnailSize,
1260}
1261
1262/// Owned personal-cache install request for async or runtime-specific adapters.
1263///
1264/// Constructing this request does not perform filesystem I/O. Normalization and installation happen
1265/// only when [`Self::install_path`] or [`Self::install_png_bytes`] is called. Constructing a
1266/// [`ReadablePersonalOriginalIdentity`] from a local path performs blocking filesystem I/O, so async callers
1267/// should do that inside their runtime's blocking adapter too.
1268///
1269/// ```no_run
1270/// use xdg_thumbnail::{
1271///     PersonalCacheRoot, PersonalThumbnailInstallRequest, ReadablePersonalOriginalIdentity, ThumbnailSize,
1272/// };
1273///
1274/// fn spawn_blocking<F, R>(operation: F) -> R
1275/// where
1276///     F: FnOnce() -> R + Send + 'static,
1277///     R: Send + 'static,
1278/// {
1279///     operation()
1280/// }
1281///
1282/// fn render_thumbnail_png() -> Vec<u8> {
1283///     unimplemented!("return PNG bytes produced by the caller's renderer")
1284/// }
1285///
1286/// fn main() -> xdg_thumbnail::Result<()> {
1287///     let root = PersonalCacheRoot::resolve_from_env()?;
1288///     let rendered_png = render_thumbnail_png();
1289///
1290///     let installed = spawn_blocking(move || {
1291///         let original =
1292///             ReadablePersonalOriginalIdentity::from_local_path("/home/alice/Pictures/photo.png")?;
1293///         let request = PersonalThumbnailInstallRequest::new(
1294///             root,
1295///             original,
1296///             ThumbnailSize::Normal,
1297///             rendered_png,
1298///         );
1299///         request.install_png_bytes()
1300///     })?;
1301///     let _path = installed.path();
1302///     Ok(())
1303/// }
1304/// ```
1305#[derive(Debug, Eq, PartialEq)]
1306pub struct PersonalThumbnailInstallRequest {
1307    root: PersonalCacheRoot,
1308    original: ReadablePersonalOriginalIdentity,
1309    size: ThumbnailSize,
1310    rendered_png: Vec<u8>,
1311}
1312
1313impl PersonalThumbnailInstallRequest {
1314    /// Creates an owned personal-cache install request.
1315    #[must_use]
1316    pub fn new(
1317        root: PersonalCacheRoot,
1318        original: ReadablePersonalOriginalIdentity,
1319        size: ThumbnailSize,
1320        rendered_png: Vec<u8>,
1321    ) -> Self {
1322        Self {
1323            root,
1324            original,
1325            size,
1326            rendered_png,
1327        }
1328    }
1329
1330    /// Normalizes rendered PNG data, installs a personal-cache thumbnail, and returns its path.
1331    ///
1332    /// # Errors
1333    ///
1334    /// Returns the same errors as [`PersonalCacheRoot::install_thumbnail_returning_path`].
1335    pub fn install_path(self) -> Result<InstalledThumbnailPath> {
1336        let Self {
1337            root,
1338            original,
1339            size,
1340            rendered_png,
1341        } = self;
1342        root.install_thumbnail_returning_path(&original, size, &rendered_png)
1343    }
1344
1345    /// Normalizes rendered PNG data, installs a personal-cache thumbnail, and returns final PNG bytes.
1346    ///
1347    /// # Errors
1348    ///
1349    /// Returns the same errors as [`PersonalCacheRoot::install_thumbnail_returning_png_bytes`].
1350    pub fn install_png_bytes(self) -> Result<InstalledThumbnailPngBytes> {
1351        let Self {
1352            root,
1353            original,
1354            size,
1355            rendered_png,
1356        } = self;
1357        root.install_thumbnail_returning_png_bytes(&original, size, &rendered_png)
1358    }
1359
1360    /// Splits this request into its owned parts.
1361    #[must_use]
1362    pub fn into_parts(self) -> PersonalThumbnailInstallRequestParts {
1363        PersonalThumbnailInstallRequestParts {
1364            root: self.root,
1365            original: self.original,
1366            size: self.size,
1367            rendered_png: self.rendered_png,
1368        }
1369    }
1370}
1371
1372/// Owned parts of [`PersonalThumbnailInstallRequest`].
1373#[derive(Debug, Eq, PartialEq)]
1374#[non_exhaustive]
1375pub struct PersonalThumbnailInstallRequestParts {
1376    /// Personal thumbnail cache root.
1377    pub root: PersonalCacheRoot,
1378    /// Readability-confirmed original identity.
1379    pub original: ReadablePersonalOriginalIdentity,
1380    /// Requested thumbnail size.
1381    pub size: ThumbnailSize,
1382    /// Caller-rendered PNG bytes.
1383    pub rendered_png: Vec<u8>,
1384}
1385
1386/// Owned personal-cache raw install request for async or runtime-specific adapters.
1387///
1388/// Constructing this request does not perform filesystem I/O. Raw conversion, normalization, and
1389/// installation happen only when [`Self::install_path`] or [`Self::install_png_bytes`] is called.
1390#[derive(Debug, Eq, PartialEq)]
1391pub struct PersonalThumbnailRawInstallRequest {
1392    root: PersonalCacheRoot,
1393    original: ReadablePersonalOriginalIdentity,
1394    size: ThumbnailSize,
1395    image: OwnedRawThumbnailImage,
1396}
1397
1398impl PersonalThumbnailRawInstallRequest {
1399    /// Creates an owned personal-cache raw install request.
1400    #[must_use]
1401    pub fn new(
1402        root: PersonalCacheRoot,
1403        original: ReadablePersonalOriginalIdentity,
1404        size: ThumbnailSize,
1405        image: OwnedRawThumbnailImage,
1406    ) -> Self {
1407        Self {
1408            root,
1409            original,
1410            size,
1411            image,
1412        }
1413    }
1414
1415    /// Normalizes raw pixel data, installs a personal-cache thumbnail, and returns its path.
1416    ///
1417    /// # Errors
1418    ///
1419    /// Returns the same errors as [`PersonalCacheRoot::install_raw_thumbnail_returning_path`].
1420    pub fn install_path(self) -> Result<InstalledThumbnailPath> {
1421        let Self {
1422            root,
1423            original,
1424            size,
1425            image,
1426        } = self;
1427        root.install_raw_thumbnail_returning_path(&original, size, image.as_borrowed())
1428    }
1429
1430    /// Normalizes raw pixel data, installs a personal-cache thumbnail, and returns final PNG bytes.
1431    ///
1432    /// # Errors
1433    ///
1434    /// Returns the same errors as [`PersonalCacheRoot::install_raw_thumbnail_returning_png_bytes`].
1435    pub fn install_png_bytes(self) -> Result<InstalledThumbnailPngBytes> {
1436        let Self {
1437            root,
1438            original,
1439            size,
1440            image,
1441        } = self;
1442        root.install_raw_thumbnail_returning_png_bytes(&original, size, image.as_borrowed())
1443    }
1444
1445    /// Splits this request into its owned parts.
1446    #[must_use]
1447    pub fn into_parts(self) -> PersonalThumbnailRawInstallRequestParts {
1448        PersonalThumbnailRawInstallRequestParts {
1449            root: self.root,
1450            original: self.original,
1451            size: self.size,
1452            image: self.image,
1453        }
1454    }
1455}
1456
1457/// Owned parts of [`PersonalThumbnailRawInstallRequest`].
1458#[derive(Debug, Eq, PartialEq)]
1459#[non_exhaustive]
1460pub struct PersonalThumbnailRawInstallRequestParts {
1461    /// Personal thumbnail cache root.
1462    pub root: PersonalCacheRoot,
1463    /// Readability-confirmed original identity.
1464    pub original: ReadablePersonalOriginalIdentity,
1465    /// Requested thumbnail size.
1466    pub size: ThumbnailSize,
1467    /// Validated raw thumbnail image.
1468    pub image: OwnedRawThumbnailImage,
1469}
1470
1471/// Owned failure-entry write request for async or runtime-specific adapters.
1472///
1473/// Constructing this request does not perform filesystem I/O. The failure entry is written only
1474/// when [`Self::write_path`] or [`Self::write_png_bytes`] is called.
1475#[derive(Clone, Debug, Eq, PartialEq)]
1476pub struct FailureEntryWriteRequest {
1477    root: PersonalCacheRoot,
1478    original: ReadablePersonalOriginalIdentity,
1479    namespace: FailureNamespace,
1480}
1481
1482impl FailureEntryWriteRequest {
1483    /// Creates an owned failure-entry write request.
1484    #[must_use]
1485    pub fn new(
1486        root: PersonalCacheRoot,
1487        original: ReadablePersonalOriginalIdentity,
1488        namespace: FailureNamespace,
1489    ) -> Self {
1490        Self {
1491            root,
1492            original,
1493            namespace,
1494        }
1495    }
1496
1497    /// Writes a deterministic 1x1 transparent failure entry and returns its path.
1498    ///
1499    /// # Errors
1500    ///
1501    /// Returns the same errors as [`PersonalCacheRoot::write_failure_entry_returning_path`].
1502    pub fn write_path(self) -> Result<InstalledThumbnailPath> {
1503        let Self {
1504            root,
1505            original,
1506            namespace,
1507        } = self;
1508        root.write_failure_entry_returning_path(&original, &namespace)
1509    }
1510
1511    /// Writes a deterministic 1x1 transparent failure entry and returns final PNG bytes.
1512    ///
1513    /// # Errors
1514    ///
1515    /// Returns the same errors as [`PersonalCacheRoot::write_failure_entry_returning_png_bytes`].
1516    pub fn write_png_bytes(self) -> Result<InstalledThumbnailPngBytes> {
1517        let Self {
1518            root,
1519            original,
1520            namespace,
1521        } = self;
1522        root.write_failure_entry_returning_png_bytes(&original, &namespace)
1523    }
1524
1525    /// Splits this request into its owned parts.
1526    #[must_use]
1527    pub fn into_parts(self) -> FailureEntryWriteRequestParts {
1528        FailureEntryWriteRequestParts {
1529            root: self.root,
1530            original: self.original,
1531            namespace: self.namespace,
1532        }
1533    }
1534}
1535
1536/// Owned parts of [`FailureEntryWriteRequest`].
1537#[derive(Clone, Debug, Eq, PartialEq)]
1538#[non_exhaustive]
1539pub struct FailureEntryWriteRequestParts {
1540    /// Personal thumbnail cache root.
1541    pub root: PersonalCacheRoot,
1542    /// Readability-confirmed original identity.
1543    pub original: ReadablePersonalOriginalIdentity,
1544    /// Failure-entry namespace.
1545    pub namespace: FailureNamespace,
1546}
1547
1548/// Owned personal-cache inspection request for async or runtime-specific adapters.
1549///
1550/// Constructing this request does not perform filesystem I/O. Inspection happens only when
1551/// [`Self::inspect`] is called.
1552#[derive(Clone, Debug, Eq, PartialEq)]
1553pub struct PersonalThumbnailInspectionRequest {
1554    root: PersonalCacheRoot,
1555    sizes: Vec<ThumbnailSize>,
1556    nonstandard_entry_policy: NonstandardEntryPolicy,
1557}
1558
1559impl PersonalThumbnailInspectionRequest {
1560    /// Creates an owned personal-cache inspection request.
1561    #[must_use]
1562    pub fn new(
1563        root: PersonalCacheRoot,
1564        sizes: Vec<ThumbnailSize>,
1565        nonstandard_entry_policy: NonstandardEntryPolicy,
1566    ) -> Self {
1567        Self {
1568            root,
1569            sizes,
1570            nonstandard_entry_policy,
1571        }
1572    }
1573
1574    /// Inspects standard successful thumbnail size directories.
1575    ///
1576    /// # Errors
1577    ///
1578    /// Returns the same errors as [`PersonalCacheRoot::inspect_thumbnails`].
1579    pub fn inspect(self) -> Result<Vec<CacheEntryInspection>> {
1580        let Self {
1581            root,
1582            sizes,
1583            nonstandard_entry_policy,
1584        } = self;
1585        root.inspect_thumbnails(&sizes, nonstandard_entry_policy)
1586    }
1587
1588    /// Splits this request into its owned parts.
1589    #[must_use]
1590    pub fn into_parts(self) -> PersonalThumbnailInspectionRequestParts {
1591        PersonalThumbnailInspectionRequestParts {
1592            root: self.root,
1593            sizes: self.sizes,
1594            nonstandard_entry_policy: self.nonstandard_entry_policy,
1595        }
1596    }
1597}
1598
1599/// Owned parts of [`PersonalThumbnailInspectionRequest`].
1600#[derive(Clone, Debug, Eq, PartialEq)]
1601#[non_exhaustive]
1602pub struct PersonalThumbnailInspectionRequestParts {
1603    /// Personal thumbnail cache root.
1604    pub root: PersonalCacheRoot,
1605    /// Successful thumbnail sizes to inspect.
1606    pub sizes: Vec<ThumbnailSize>,
1607    /// Policy for nonstandard cache directory entries.
1608    pub nonstandard_entry_policy: NonstandardEntryPolicy,
1609}
1610
1611/// Owned failure-entry inspection request for async or runtime-specific adapters.
1612///
1613/// Constructing this request does not perform filesystem I/O. Inspection happens only when
1614/// [`Self::inspect`] is called.
1615#[derive(Clone, Debug, Eq, PartialEq)]
1616pub struct FailureEntryInspectionRequest {
1617    root: PersonalCacheRoot,
1618    nonstandard_entry_policy: NonstandardEntryPolicy,
1619}
1620
1621impl FailureEntryInspectionRequest {
1622    /// Creates an owned failure-entry inspection request.
1623    #[must_use]
1624    pub fn new(root: PersonalCacheRoot, nonstandard_entry_policy: NonstandardEntryPolicy) -> Self {
1625        Self {
1626            root,
1627            nonstandard_entry_policy,
1628        }
1629    }
1630
1631    /// Inspects direct files in immediate real failure-entry namespaces.
1632    ///
1633    /// # Errors
1634    ///
1635    /// Returns the same errors as [`PersonalCacheRoot::inspect_failure_entries`].
1636    pub fn inspect(self) -> Result<Vec<CacheEntryInspection>> {
1637        let Self {
1638            root,
1639            nonstandard_entry_policy,
1640        } = self;
1641        root.inspect_failure_entries(nonstandard_entry_policy)
1642    }
1643
1644    /// Splits this request into its owned parts.
1645    #[must_use]
1646    pub fn into_parts(self) -> FailureEntryInspectionRequestParts {
1647        FailureEntryInspectionRequestParts {
1648            root: self.root,
1649            nonstandard_entry_policy: self.nonstandard_entry_policy,
1650        }
1651    }
1652}
1653
1654/// Owned parts of [`FailureEntryInspectionRequest`].
1655#[derive(Clone, Debug, Eq, PartialEq)]
1656#[non_exhaustive]
1657pub struct FailureEntryInspectionRequestParts {
1658    /// Personal thumbnail cache root.
1659    pub root: PersonalCacheRoot,
1660    /// Policy for nonstandard cache directory entries.
1661    pub nonstandard_entry_policy: NonstandardEntryPolicy,
1662}
1663
1664/// Owned shared-repository lookup request for async or runtime-specific adapters.
1665///
1666/// Constructing this request does not perform filesystem I/O. Validation happens only when
1667/// [`Self::lookup_path`], [`Self::lookup_png_bytes`], or [`Self::lookup_rgba8`] is called.
1668#[derive(Clone, Debug, Eq, PartialEq)]
1669pub struct SharedThumbnailLookupRequest {
1670    context: SharedRepositoryContext,
1671    original_facts: SharedOriginalFacts,
1672    size: ThumbnailSize,
1673}
1674
1675impl SharedThumbnailLookupRequest {
1676    /// Creates an owned shared-repository lookup request.
1677    #[must_use]
1678    pub fn new(
1679        context: SharedRepositoryContext,
1680        original_facts: SharedOriginalFacts,
1681        size: ThumbnailSize,
1682    ) -> Self {
1683        Self {
1684            context,
1685            original_facts,
1686            size,
1687        }
1688    }
1689
1690    /// Returns a validated shared-repository path for the owned request.
1691    ///
1692    /// # Errors
1693    ///
1694    /// Returns the same errors as [`SharedRepositoryContext::lookup_thumbnail_path`].
1695    pub fn lookup_path(self) -> Result<SharedThumbnailLookup<ThumbnailPathLookupEntry>> {
1696        let Self {
1697            context,
1698            original_facts,
1699            size,
1700        } = self;
1701        context.lookup_thumbnail_path(original_facts, size)
1702    }
1703
1704    /// Returns exact validated shared-repository PNG bytes for the owned request.
1705    ///
1706    /// # Errors
1707    ///
1708    /// Returns the same errors as [`SharedRepositoryContext::lookup_thumbnail_png_bytes`].
1709    pub fn lookup_png_bytes(self) -> Result<SharedThumbnailLookup<ThumbnailPngBytesLookupEntry>> {
1710        let Self {
1711            context,
1712            original_facts,
1713            size,
1714        } = self;
1715        context.lookup_thumbnail_png_bytes(original_facts, size)
1716    }
1717
1718    /// Returns decoded tightly packed RGBA8 pixels for the owned request.
1719    ///
1720    /// # Errors
1721    ///
1722    /// Returns the same errors as [`SharedRepositoryContext::lookup_thumbnail_rgba8`].
1723    pub fn lookup_rgba8(self) -> Result<SharedThumbnailLookup<ThumbnailRgba8LookupEntry>> {
1724        let Self {
1725            context,
1726            original_facts,
1727            size,
1728        } = self;
1729        context.lookup_thumbnail_rgba8(original_facts, size)
1730    }
1731
1732    /// Returns decoded display RGBA8 pixels for the owned request.
1733    ///
1734    /// # Errors
1735    ///
1736    /// Returns the same errors as [`SharedRepositoryContext::lookup_display_thumbnail_rgba8`].
1737    pub fn lookup_display_rgba8(
1738        self,
1739    ) -> Result<SharedThumbnailLookup<DisplayThumbnailRgba8LookupEntry>> {
1740        let Self {
1741            context,
1742            original_facts,
1743            size,
1744        } = self;
1745        context.lookup_display_thumbnail_rgba8(original_facts, size)
1746    }
1747
1748    /// Splits this request into its owned parts.
1749    #[must_use]
1750    pub fn into_parts(self) -> SharedThumbnailLookupRequestParts {
1751        SharedThumbnailLookupRequestParts {
1752            context: self.context,
1753            original_facts: self.original_facts,
1754            size: self.size,
1755        }
1756    }
1757}
1758
1759/// Owned parts of [`SharedThumbnailLookupRequest`].
1760#[derive(Clone, Debug, Eq, PartialEq)]
1761#[non_exhaustive]
1762pub struct SharedThumbnailLookupRequestParts {
1763    /// Shared repository lookup context.
1764    pub context: SharedRepositoryContext,
1765    /// Shared original freshness facts and metadata policy.
1766    pub original_facts: SharedOriginalFacts,
1767    /// Requested thumbnail size.
1768    pub size: ThumbnailSize,
1769}
1770
1771/// Owned shared-to-personal fallback materialization request for async adapters.
1772///
1773/// Constructing this request does not perform filesystem I/O. Validation and materialization
1774/// happen only when [`Self::materialize_path`] or [`Self::materialize_png_bytes`] is called.
1775#[derive(Clone, Debug, Eq, PartialEq)]
1776pub struct SharedToPersonalThumbnailMaterializationRequest {
1777    personal_root: PersonalCacheRoot,
1778    shared_context: SharedRepositoryContext,
1779    original_facts: SharedOriginalFacts,
1780    size: ThumbnailSize,
1781}
1782
1783impl SharedToPersonalThumbnailMaterializationRequest {
1784    /// Creates an owned shared-to-personal materialization request.
1785    #[must_use]
1786    pub fn new(
1787        personal_root: PersonalCacheRoot,
1788        shared_context: SharedRepositoryContext,
1789        original_facts: SharedOriginalFacts,
1790        size: ThumbnailSize,
1791    ) -> Self {
1792        Self {
1793            personal_root,
1794            shared_context,
1795            original_facts,
1796            size,
1797        }
1798    }
1799
1800    /// Materializes the requested shared thumbnail into the personal cache and returns its path.
1801    ///
1802    /// # Errors
1803    ///
1804    /// Returns the same errors as
1805    /// [`PersonalCacheRoot::materialize_shared_thumbnail_returning_path`].
1806    pub fn materialize_path(self) -> Result<SharedThumbnailLookup<MaterializedThumbnailPath>> {
1807        let Self {
1808            personal_root,
1809            shared_context,
1810            original_facts,
1811            size,
1812        } = self;
1813        personal_root.materialize_shared_thumbnail_returning_path(
1814            &shared_context,
1815            original_facts,
1816            size,
1817        )
1818    }
1819
1820    /// Materializes the requested shared thumbnail into the personal cache and returns final PNG bytes.
1821    ///
1822    /// # Errors
1823    ///
1824    /// Returns the same errors as
1825    /// [`PersonalCacheRoot::materialize_shared_thumbnail_returning_png_bytes`].
1826    pub fn materialize_png_bytes(
1827        self,
1828    ) -> Result<SharedThumbnailLookup<MaterializedThumbnailPngBytes>> {
1829        let Self {
1830            personal_root,
1831            shared_context,
1832            original_facts,
1833            size,
1834        } = self;
1835        personal_root.materialize_shared_thumbnail_returning_png_bytes(
1836            &shared_context,
1837            original_facts,
1838            size,
1839        )
1840    }
1841
1842    /// Splits this request into its owned parts.
1843    #[must_use]
1844    pub fn into_parts(self) -> SharedToPersonalThumbnailMaterializationRequestParts {
1845        SharedToPersonalThumbnailMaterializationRequestParts {
1846            personal_root: self.personal_root,
1847            shared_context: self.shared_context,
1848            original_facts: self.original_facts,
1849            size: self.size,
1850        }
1851    }
1852}
1853
1854/// Owned parts of [`SharedToPersonalThumbnailMaterializationRequest`].
1855#[derive(Clone, Debug, Eq, PartialEq)]
1856#[non_exhaustive]
1857pub struct SharedToPersonalThumbnailMaterializationRequestParts {
1858    /// Destination personal thumbnail cache root.
1859    pub personal_root: PersonalCacheRoot,
1860    /// Source shared repository lookup context.
1861    pub shared_context: SharedRepositoryContext,
1862    /// Shared original freshness facts and metadata policy.
1863    pub original_facts: SharedOriginalFacts,
1864    /// Requested thumbnail size.
1865    pub size: ThumbnailSize,
1866}
1867
1868/// Owned shared-repository inspection request for async or runtime-specific adapters.
1869///
1870/// Constructing this request does not perform filesystem I/O. Inspection happens only when
1871/// [`Self::inspect`] is called.
1872#[derive(Clone, Debug, Eq, PartialEq)]
1873pub struct SharedThumbnailInspectionRequest {
1874    context: SharedRepositoryContext,
1875    sizes: Vec<ThumbnailSize>,
1876    original: SharedOriginalMetadata,
1877}
1878
1879impl SharedThumbnailInspectionRequest {
1880    /// Creates an owned shared-repository inspection request.
1881    #[must_use]
1882    pub fn new(
1883        context: SharedRepositoryContext,
1884        sizes: Vec<ThumbnailSize>,
1885        original: SharedOriginalMetadata,
1886    ) -> Self {
1887        Self {
1888            context,
1889            sizes,
1890            original,
1891        }
1892    }
1893
1894    /// Inspects existing shared-repository thumbnails without exposing removal handles.
1895    ///
1896    /// # Errors
1897    ///
1898    /// Returns the same errors as [`SharedRepositoryContext::inspect_thumbnails`].
1899    pub fn inspect(self) -> Result<Vec<SharedCacheEntryInspection>> {
1900        let Self {
1901            context,
1902            sizes,
1903            original,
1904        } = self;
1905        context.inspect_thumbnails(&sizes, original)
1906    }
1907
1908    /// Splits this request into its owned parts.
1909    #[must_use]
1910    pub fn into_parts(self) -> SharedThumbnailInspectionRequestParts {
1911        SharedThumbnailInspectionRequestParts {
1912            context: self.context,
1913            sizes: self.sizes,
1914            original: self.original,
1915        }
1916    }
1917}
1918
1919/// Owned parts of [`SharedThumbnailInspectionRequest`].
1920#[derive(Clone, Debug, Eq, PartialEq)]
1921#[non_exhaustive]
1922pub struct SharedThumbnailInspectionRequestParts {
1923    /// Shared repository lookup context.
1924    pub context: SharedRepositoryContext,
1925    /// Successful thumbnail sizes to inspect.
1926    pub sizes: Vec<ThumbnailSize>,
1927    /// Policy-neutral shared original metadata facts.
1928    pub original: SharedOriginalMetadata,
1929}
1930
1931fn only_unverifiable_original(problems: &[CacheEntryProblem]) -> bool {
1932    !problems.is_empty()
1933        && problems
1934            .iter()
1935            .all(|problem| *problem == CacheEntryProblem::UnverifiableOriginal)
1936}
1937
1938enum CacheEntryRead {
1939    Missing,
1940    Unreadable,
1941    Bytes(Vec<u8>),
1942}
1943
1944fn read_cache_entry_no_follow(path: &Path, context: &'static str) -> Result<CacheEntryRead> {
1945    let metadata = match fs::symlink_metadata(path) {
1946        Ok(metadata) => metadata,
1947        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1948            return Ok(CacheEntryRead::Missing);
1949        }
1950        Err(source) => {
1951            return Err(ThumbnailError::io(context, Some(path.to_owned()), source));
1952        }
1953    };
1954    if metadata.file_type().is_symlink() || !metadata.is_file() {
1955        return Ok(CacheEntryRead::Unreadable);
1956    }
1957
1958    let flags = rustix::fs::OFlags::RDONLY
1959        | rustix::fs::OFlags::CLOEXEC
1960        | rustix::fs::OFlags::NOFOLLOW
1961        | rustix::fs::OFlags::NONBLOCK;
1962    let fd = match rustix::fs::open(path, flags, rustix::fs::Mode::empty()) {
1963        Ok(fd) => fd,
1964        Err(rustix::io::Errno::NOENT) => return Ok(CacheEntryRead::Missing),
1965        Err(rustix::io::Errno::LOOP | rustix::io::Errno::ISDIR | rustix::io::Errno::NOTDIR) => {
1966            return Ok(CacheEntryRead::Unreadable);
1967        }
1968        Err(rustix::io::Errno::ACCESS | rustix::io::Errno::PERM) => {
1969            return Ok(CacheEntryRead::Unreadable);
1970        }
1971        Err(source) => {
1972            return Err(ThumbnailError::io(
1973                context,
1974                Some(path.to_owned()),
1975                std::io::Error::from(source),
1976            ));
1977        }
1978    };
1979
1980    let stat = rustix::fs::fstat(&fd).map_err(|source| {
1981        ThumbnailError::io(context, Some(path.to_owned()), std::io::Error::from(source))
1982    })?;
1983    let file_type = rustix::fs::FileType::from_raw_mode(stat.st_mode);
1984    if !file_type.is_file() {
1985        return Ok(CacheEntryRead::Unreadable);
1986    }
1987
1988    let mut file = File::from(fd);
1989    let mut bytes = Vec::new();
1990    if let Err(source) = file.read_to_end(&mut bytes) {
1991        if source.kind() == std::io::ErrorKind::PermissionDenied {
1992            return Ok(CacheEntryRead::Unreadable);
1993        }
1994        return Err(ThumbnailError::io(context, Some(path.to_owned()), source));
1995    }
1996    Ok(CacheEntryRead::Bytes(bytes))
1997}
1998
1999fn shared_cache_entry_outcome(outcome: SharedValidationOutcome) -> SharedCacheEntryOutcome {
2000    match outcome {
2001        SharedValidationOutcome::FullyVerified => SharedCacheEntryOutcome::FullyVerified,
2002        SharedValidationOutcome::MetadataIncomplete => SharedCacheEntryOutcome::MetadataIncomplete,
2003        SharedValidationOutcome::Invalid(problems) if only_unverifiable_original(&problems) => {
2004            SharedCacheEntryOutcome::Unverifiable(problems)
2005        }
2006        SharedValidationOutcome::Invalid(problems) => SharedCacheEntryOutcome::Invalid(problems),
2007    }
2008}
2009
2010struct ValidatedPersonalEntry {
2011    path: PathBuf,
2012    bytes: Vec<u8>,
2013    metadata: ThumbnailMetadata,
2014}
2015
2016struct ValidatedSharedEntry {
2017    path: PathBuf,
2018    bytes: Vec<u8>,
2019    metadata: ThumbnailMetadata,
2020}
2021
2022struct MaterializedPersonalEntry {
2023    target_path: PathBuf,
2024    source_path: PathBuf,
2025    requested_size: ThumbnailSize,
2026    source_size: ThumbnailSize,
2027    written: bool,
2028    bytes: Vec<u8>,
2029}
2030
2031fn display_candidate_sizes(requested_size: ThumbnailSize) -> impl Iterator<Item = ThumbnailSize> {
2032    ThumbnailSize::all()
2033        .iter()
2034        .copied()
2035        .skip_while(move |size| *size != requested_size)
2036}
2037
2038fn personal_identity_from_shared_facts(
2039    shared: &SharedRepositoryContext,
2040    original_facts: SharedOriginalFacts,
2041) -> Result<PersonalOriginalIdentity> {
2042    let Some(mtime) = original_facts.mtime() else {
2043        return Err(ThumbnailError::invalid_metadata(
2044            "shared original mtime is required for personal materialization",
2045        ));
2046    };
2047    let original_path = shared.repository_root().join(shared.original_child_name());
2048    let uri = PersonalOriginalUri::from_absolute_path_bytes(original_path.as_os_str().as_bytes())?;
2049    let mut original = PersonalOriginalIdentity::new(uri, mtime);
2050    if let Some(size) = original_facts.original_byte_size() {
2051        original = original.with_original_byte_size(size);
2052    }
2053    Ok(original)
2054}
2055
2056fn rgba8_lookup_entry_from_parts(
2057    path: PathBuf,
2058    bytes: &[u8],
2059    metadata: ThumbnailMetadata,
2060) -> Result<ThumbnailRgba8LookupEntry> {
2061    let decoded = decode_validated_thumbnail_png_to_rgba8(bytes)?;
2062    Ok(ThumbnailRgba8LookupEntry {
2063        path,
2064        width: decoded.width,
2065        height: decoded.height,
2066        stride: decoded.stride,
2067        pixels: decoded.pixels,
2068        metadata,
2069    })
2070}
2071
2072fn display_rgba8_lookup_entry_from_parts(
2073    source_path: PathBuf,
2074    bytes: &[u8],
2075    source_metadata: ThumbnailMetadata,
2076    requested_size: ThumbnailSize,
2077    source_size: ThumbnailSize,
2078) -> Result<DisplayThumbnailRgba8LookupEntry> {
2079    let decoded = downscaled_validated_thumbnail_png_to_rgba8(bytes, requested_size)?;
2080    Ok(DisplayThumbnailRgba8LookupEntry {
2081        source_path,
2082        requested_size,
2083        source_size,
2084        width: decoded.width,
2085        height: decoded.height,
2086        stride: decoded.stride,
2087        pixels: decoded.pixels,
2088        source_metadata,
2089    })
2090}
2091
2092/// Result of a validated personal thumbnail cache lookup.
2093#[derive(Clone, Debug, Eq, PartialEq)]
2094pub enum PersonalThumbnailLookup<T> {
2095    /// The cache entry exists and passed validation.
2096    Valid(T),
2097    /// The computed cache path does not exist.
2098    Missing,
2099    /// The cache entry exists but is invalid for the requested context.
2100    Invalid(Vec<CacheEntryProblem>),
2101}
2102
2103/// Result of a validated shared thumbnail repository lookup.
2104#[derive(Clone, Debug, Eq, PartialEq)]
2105#[non_exhaustive]
2106pub enum SharedThumbnailLookup<T> {
2107    /// The cache entry exists and required metadata and PNG constraints are fully verified.
2108    FullyVerified(T),
2109    /// The cache entry is otherwise usable but lacks standard-optional shared freshness metadata.
2110    MetadataIncomplete(T),
2111    /// The computed shared cache path does not exist.
2112    Missing,
2113    /// The cache entry exists but is invalid for the requested context.
2114    Invalid(Vec<CacheEntryProblem>),
2115    /// Caller-supplied shared original facts are insufficient to verify the entry.
2116    Unverifiable(Vec<CacheEntryProblem>),
2117}
2118
2119/// Metadata acceptance policy for shared-repository thumbnail lookups.
2120#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
2121#[non_exhaustive]
2122pub enum SharedThumbnailMetadataPolicy {
2123    /// Require `Thumb::URI` and `Thumb::MTime` to be present and verified.
2124    RequireComplete,
2125    /// Accept standard-allowed missing `Thumb::URI` or `Thumb::MTime` as metadata-incomplete.
2126    AllowIncomplete,
2127}
2128
2129/// Policy-neutral original freshness facts for shared-repository validation and inspection.
2130#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
2131pub struct SharedOriginalMetadata {
2132    mtime: Option<UnixMtimeSeconds>,
2133    original_byte_size: Option<u64>,
2134}
2135
2136impl SharedOriginalMetadata {
2137    /// Creates empty shared original metadata facts.
2138    #[must_use]
2139    pub const fn new() -> Self {
2140        Self {
2141            mtime: None,
2142            original_byte_size: None,
2143        }
2144    }
2145
2146    /// Adds a known original modification time.
2147    #[must_use]
2148    pub const fn with_mtime(mut self, mtime: UnixMtimeSeconds) -> Self {
2149        self.mtime = Some(mtime);
2150        self
2151    }
2152
2153    /// Adds a known original byte size.
2154    #[must_use]
2155    pub const fn with_original_byte_size(mut self, original_byte_size: u64) -> Self {
2156        self.original_byte_size = Some(original_byte_size);
2157        self
2158    }
2159
2160    /// Returns the original modification time when known.
2161    #[must_use]
2162    pub const fn mtime(&self) -> Option<UnixMtimeSeconds> {
2163        self.mtime
2164    }
2165
2166    /// Returns the original byte size when known.
2167    #[must_use]
2168    pub const fn original_byte_size(&self) -> Option<u64> {
2169        self.original_byte_size
2170    }
2171}
2172
2173/// Shared-repository lookup facts, including the metadata acceptance policy.
2174#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
2175pub struct SharedOriginalFacts {
2176    metadata_policy: SharedThumbnailMetadataPolicy,
2177    metadata: SharedOriginalMetadata,
2178}
2179
2180impl SharedOriginalFacts {
2181    /// Creates shared lookup facts from a metadata policy and policy-neutral metadata facts.
2182    #[must_use]
2183    pub const fn new(
2184        metadata_policy: SharedThumbnailMetadataPolicy,
2185        metadata: SharedOriginalMetadata,
2186    ) -> Self {
2187        Self {
2188            metadata_policy,
2189            metadata,
2190        }
2191    }
2192
2193    /// Returns the shared lookup metadata acceptance policy.
2194    #[must_use]
2195    pub const fn metadata_policy(&self) -> SharedThumbnailMetadataPolicy {
2196        self.metadata_policy
2197    }
2198
2199    /// Returns the original modification time when known.
2200    #[must_use]
2201    pub const fn mtime(&self) -> Option<UnixMtimeSeconds> {
2202        self.metadata.mtime()
2203    }
2204
2205    /// Returns the original byte size when known.
2206    #[must_use]
2207    pub const fn original_byte_size(&self) -> Option<u64> {
2208        self.metadata.original_byte_size()
2209    }
2210
2211    /// Returns policy-neutral shared original metadata facts.
2212    #[must_use]
2213    pub const fn metadata(&self) -> SharedOriginalMetadata {
2214        self.metadata
2215    }
2216}
2217
2218/// Validation state for a shared cache entry inspection.
2219#[derive(Clone, Debug, Eq, PartialEq)]
2220#[non_exhaustive]
2221pub enum SharedCacheEntryOutcome {
2222    /// Required metadata and PNG constraints are fully verified.
2223    FullyVerified,
2224    /// Standard-optional shared freshness metadata is absent.
2225    MetadataIncomplete,
2226    /// The entry is invalid for the requested shared context.
2227    Invalid(Vec<CacheEntryProblem>),
2228    /// Caller-supplied shared original facts are insufficient to verify the entry.
2229    Unverifiable(Vec<CacheEntryProblem>),
2230}
2231
2232/// Read-only inspection facts for an existing shared thumbnail repository entry.
2233#[derive(Clone, Debug, Eq, PartialEq)]
2234pub struct SharedCacheEntryInspection {
2235    outcome: SharedCacheEntryOutcome,
2236    shared_uri: SharedRelativeOriginalUri,
2237    timestamps: ThumbnailTimestamps,
2238    size: ThumbnailSize,
2239    path: PathBuf,
2240    metadata: Option<ThumbnailMetadata>,
2241}
2242
2243impl SharedCacheEntryInspection {
2244    /// Returns the shared validation or inspection outcome.
2245    #[must_use]
2246    pub const fn outcome(&self) -> &SharedCacheEntryOutcome {
2247        &self.outcome
2248    }
2249
2250    /// Returns the shared relative URI used for hashing and metadata comparison.
2251    #[must_use]
2252    pub const fn shared_uri(&self) -> &SharedRelativeOriginalUri {
2253        &self.shared_uri
2254    }
2255
2256    /// Returns timestamp facts.
2257    #[must_use]
2258    pub const fn timestamps(&self) -> &ThumbnailTimestamps {
2259        &self.timestamps
2260    }
2261
2262    /// Returns the successful-thumbnail size namespace.
2263    #[must_use]
2264    pub const fn size(&self) -> ThumbnailSize {
2265        self.size
2266    }
2267
2268    /// Returns the inspected shared cache path.
2269    #[must_use]
2270    pub fn path(&self) -> &Path {
2271        &self.path
2272    }
2273
2274    /// Returns parsed metadata when the entry was a readable PNG.
2275    #[must_use]
2276    pub const fn metadata(&self) -> Option<&ThumbnailMetadata> {
2277        self.metadata.as_ref()
2278    }
2279
2280    /// Splits this inspection into its owned facts.
2281    #[must_use]
2282    pub fn into_parts(self) -> SharedCacheEntryInspectionParts {
2283        SharedCacheEntryInspectionParts {
2284            outcome: self.outcome,
2285            shared_uri: self.shared_uri,
2286            timestamps: self.timestamps,
2287            size: self.size,
2288            path: self.path,
2289            metadata: self.metadata,
2290        }
2291    }
2292}
2293
2294/// Owned parts of [`SharedCacheEntryInspection`].
2295#[derive(Clone, Debug, Eq, PartialEq)]
2296#[non_exhaustive]
2297pub struct SharedCacheEntryInspectionParts {
2298    /// Shared validation or inspection outcome.
2299    pub outcome: SharedCacheEntryOutcome,
2300    /// Shared relative URI used for hashing and metadata comparison.
2301    pub shared_uri: SharedRelativeOriginalUri,
2302    /// Timestamp facts.
2303    pub timestamps: ThumbnailTimestamps,
2304    /// Successful-thumbnail size namespace.
2305    pub size: ThumbnailSize,
2306    /// Inspected shared cache path.
2307    pub path: PathBuf,
2308    /// Parsed metadata when the entry was a readable PNG.
2309    pub metadata: Option<ThumbnailMetadata>,
2310}
2311
2312/// A validated cache path and metadata facts.
2313#[derive(Clone, Debug, Eq, PartialEq)]
2314pub struct ThumbnailPathLookupEntry {
2315    path: PathBuf,
2316    metadata: ThumbnailMetadata,
2317}
2318
2319impl ThumbnailPathLookupEntry {
2320    /// Returns the path that was validated.
2321    #[must_use]
2322    pub fn path(&self) -> &Path {
2323        &self.path
2324    }
2325
2326    /// Returns metadata parsed from the validated PNG.
2327    #[must_use]
2328    pub const fn metadata(&self) -> &ThumbnailMetadata {
2329        &self.metadata
2330    }
2331
2332    /// Splits this result into its owned path and metadata.
2333    #[must_use]
2334    pub fn into_parts(self) -> ThumbnailPathLookupEntryParts {
2335        ThumbnailPathLookupEntryParts {
2336            path: self.path,
2337            metadata: self.metadata,
2338        }
2339    }
2340}
2341
2342/// Owned parts of [`ThumbnailPathLookupEntry`].
2343#[derive(Clone, Debug, Eq, PartialEq)]
2344#[non_exhaustive]
2345pub struct ThumbnailPathLookupEntryParts {
2346    /// Path that was validated.
2347    pub path: PathBuf,
2348    /// Metadata parsed from the validated PNG.
2349    pub metadata: ThumbnailMetadata,
2350}
2351
2352/// Exact validated PNG bytes and metadata facts.
2353#[derive(Debug, Eq, PartialEq)]
2354pub struct ThumbnailPngBytesLookupEntry {
2355    path: PathBuf,
2356    bytes: Vec<u8>,
2357    metadata: ThumbnailMetadata,
2358}
2359
2360impl ThumbnailPngBytesLookupEntry {
2361    /// Returns the path from which the PNG bytes were validated.
2362    #[must_use]
2363    pub fn path(&self) -> &Path {
2364        &self.path
2365    }
2366
2367    /// Returns the exact PNG bytes that passed validation.
2368    #[must_use]
2369    pub fn png_bytes(&self) -> &[u8] {
2370        &self.bytes
2371    }
2372
2373    /// Returns metadata parsed from the validated PNG.
2374    #[must_use]
2375    pub const fn metadata(&self) -> &ThumbnailMetadata {
2376        &self.metadata
2377    }
2378
2379    /// Splits this result into its owned path, PNG bytes, and metadata.
2380    #[must_use]
2381    pub fn into_parts(self) -> ThumbnailPngBytesLookupEntryParts {
2382        ThumbnailPngBytesLookupEntryParts {
2383            path: self.path,
2384            png_bytes: self.bytes,
2385            metadata: self.metadata,
2386        }
2387    }
2388}
2389
2390/// Owned parts of [`ThumbnailPngBytesLookupEntry`].
2391#[derive(Debug, Eq, PartialEq)]
2392#[non_exhaustive]
2393pub struct ThumbnailPngBytesLookupEntryParts {
2394    /// Path from which the PNG bytes were validated.
2395    pub path: PathBuf,
2396    /// Exact PNG bytes that passed validation.
2397    pub png_bytes: Vec<u8>,
2398    /// Metadata parsed from the validated PNG.
2399    pub metadata: ThumbnailMetadata,
2400}
2401
2402/// Decoded tightly packed RGBA8 pixels and metadata facts from a validated cache PNG.
2403///
2404/// Pixels are row-major `[red, green, blue, alpha]` bytes with straight alpha and
2405/// `stride == width * 4`.
2406#[derive(Debug, Eq, PartialEq)]
2407pub struct ThumbnailRgba8LookupEntry {
2408    path: PathBuf,
2409    width: u32,
2410    height: u32,
2411    stride: usize,
2412    pixels: Vec<u8>,
2413    metadata: ThumbnailMetadata,
2414}
2415
2416impl ThumbnailRgba8LookupEntry {
2417    /// Returns the path from which the PNG was validated and decoded.
2418    #[must_use]
2419    pub fn path(&self) -> &Path {
2420        &self.path
2421    }
2422
2423    /// Returns the decoded image width in pixels.
2424    #[must_use]
2425    pub const fn width(&self) -> u32 {
2426        self.width
2427    }
2428
2429    /// Returns the decoded image height in pixels.
2430    #[must_use]
2431    pub const fn height(&self) -> u32 {
2432        self.height
2433    }
2434
2435    /// Returns the row stride in bytes.
2436    ///
2437    /// RGBA8 lookup results are tightly packed, so this is always `width * 4`.
2438    #[must_use]
2439    pub const fn stride(&self) -> usize {
2440        self.stride
2441    }
2442
2443    /// Returns the decoded row-major RGBA8 pixel buffer.
2444    #[must_use]
2445    pub fn pixels(&self) -> &[u8] {
2446        &self.pixels
2447    }
2448
2449    /// Returns metadata parsed from the validated PNG.
2450    #[must_use]
2451    pub const fn metadata(&self) -> &ThumbnailMetadata {
2452        &self.metadata
2453    }
2454
2455    /// Splits this result into its owned path, dimensions, stride, RGBA8 pixels, and metadata.
2456    #[must_use]
2457    pub fn into_parts(self) -> ThumbnailRgba8LookupEntryParts {
2458        ThumbnailRgba8LookupEntryParts {
2459            path: self.path,
2460            width: self.width,
2461            height: self.height,
2462            stride: self.stride,
2463            pixels: self.pixels,
2464            metadata: self.metadata,
2465        }
2466    }
2467}
2468
2469/// Owned parts of [`ThumbnailRgba8LookupEntry`].
2470#[derive(Debug, Eq, PartialEq)]
2471#[non_exhaustive]
2472pub struct ThumbnailRgba8LookupEntryParts {
2473    /// Path from which the PNG was validated and decoded.
2474    pub path: PathBuf,
2475    /// Decoded image width in pixels.
2476    pub width: u32,
2477    /// Decoded image height in pixels.
2478    pub height: u32,
2479    /// Row stride in bytes.
2480    pub stride: usize,
2481    /// Decoded row-major RGBA8 pixel buffer.
2482    pub pixels: Vec<u8>,
2483    /// Metadata parsed from the validated PNG.
2484    pub metadata: ThumbnailMetadata,
2485}
2486
2487/// Decoded display RGBA8 pixels from an exact or larger validated cache PNG.
2488///
2489/// Pixels are row-major `[red, green, blue, alpha]` bytes with straight alpha and
2490/// `stride == width * 4`. When `source_size()` differs from `requested_size()`, pixels were
2491/// derived from a larger source namespace and constrained to the requested namespace dimensions.
2492#[derive(Debug, Eq, PartialEq)]
2493pub struct DisplayThumbnailRgba8LookupEntry {
2494    source_path: PathBuf,
2495    requested_size: ThumbnailSize,
2496    source_size: ThumbnailSize,
2497    width: u32,
2498    height: u32,
2499    stride: usize,
2500    pixels: Vec<u8>,
2501    source_metadata: ThumbnailMetadata,
2502}
2503
2504impl DisplayThumbnailRgba8LookupEntry {
2505    /// Returns the source cache path that was validated and decoded.
2506    #[must_use]
2507    pub fn source_path(&self) -> &Path {
2508        &self.source_path
2509    }
2510
2511    /// Returns the requested display size namespace.
2512    #[must_use]
2513    pub const fn requested_size(&self) -> ThumbnailSize {
2514        self.requested_size
2515    }
2516
2517    /// Returns the cache namespace that supplied the source PNG.
2518    #[must_use]
2519    pub const fn source_size(&self) -> ThumbnailSize {
2520        self.source_size
2521    }
2522
2523    /// Returns whether the display pixels came from a larger namespace than requested.
2524    #[must_use]
2525    pub const fn is_derived(&self) -> bool {
2526        self.requested_size as u8 != self.source_size as u8
2527    }
2528
2529    /// Returns the decoded display width in pixels.
2530    #[must_use]
2531    pub const fn width(&self) -> u32 {
2532        self.width
2533    }
2534
2535    /// Returns the decoded display height in pixels.
2536    #[must_use]
2537    pub const fn height(&self) -> u32 {
2538        self.height
2539    }
2540
2541    /// Returns the row stride in bytes.
2542    #[must_use]
2543    pub const fn stride(&self) -> usize {
2544        self.stride
2545    }
2546
2547    /// Returns the decoded row-major RGBA8 display pixel buffer.
2548    #[must_use]
2549    pub fn pixels(&self) -> &[u8] {
2550        &self.pixels
2551    }
2552
2553    /// Returns metadata parsed from the source PNG.
2554    #[must_use]
2555    pub const fn source_metadata(&self) -> &ThumbnailMetadata {
2556        &self.source_metadata
2557    }
2558
2559    /// Splits this result into its owned source path, size facts, pixels, and source metadata.
2560    #[must_use]
2561    pub fn into_parts(self) -> DisplayThumbnailRgba8LookupEntryParts {
2562        DisplayThumbnailRgba8LookupEntryParts {
2563            source_path: self.source_path,
2564            requested_size: self.requested_size,
2565            source_size: self.source_size,
2566            width: self.width,
2567            height: self.height,
2568            stride: self.stride,
2569            pixels: self.pixels,
2570            source_metadata: self.source_metadata,
2571        }
2572    }
2573}
2574
2575/// Owned parts of [`DisplayThumbnailRgba8LookupEntry`].
2576#[derive(Debug, Eq, PartialEq)]
2577#[non_exhaustive]
2578pub struct DisplayThumbnailRgba8LookupEntryParts {
2579    /// Source cache path that was validated and decoded.
2580    pub source_path: PathBuf,
2581    /// Requested display size namespace.
2582    pub requested_size: ThumbnailSize,
2583    /// Cache namespace that supplied the source PNG.
2584    pub source_size: ThumbnailSize,
2585    /// Decoded display width in pixels.
2586    pub width: u32,
2587    /// Decoded display height in pixels.
2588    pub height: u32,
2589    /// Row stride in bytes.
2590    pub stride: usize,
2591    /// Decoded row-major RGBA8 display pixel buffer.
2592    pub pixels: Vec<u8>,
2593    /// Metadata parsed from the source PNG.
2594    pub source_metadata: ThumbnailMetadata,
2595}
2596
2597/// Path result of a successful personal-cache install or failure-entry write.
2598#[derive(Clone, Debug, Eq, PartialEq)]
2599pub struct InstalledThumbnailPath {
2600    path: PathBuf,
2601}
2602
2603impl InstalledThumbnailPath {
2604    /// Returns the installed cache path.
2605    #[must_use]
2606    pub fn path(&self) -> &Path {
2607        &self.path
2608    }
2609
2610    /// Returns the installed path as an owned [`PathBuf`].
2611    #[must_use]
2612    pub fn into_path_buf(self) -> PathBuf {
2613        self.path
2614    }
2615}
2616
2617impl AsRef<Path> for InstalledThumbnailPath {
2618    fn as_ref(&self) -> &Path {
2619        self.path()
2620    }
2621}
2622
2623/// Path result of explicit fallback materialization into the personal cache.
2624#[derive(Clone, Debug, Eq, PartialEq)]
2625pub struct MaterializedThumbnailPath {
2626    target_path: PathBuf,
2627    source_path: PathBuf,
2628    requested_size: ThumbnailSize,
2629    source_size: ThumbnailSize,
2630    written: bool,
2631}
2632
2633impl MaterializedThumbnailPath {
2634    /// Returns the requested personal-cache target path.
2635    #[must_use]
2636    pub fn target_path(&self) -> &Path {
2637        &self.target_path
2638    }
2639
2640    /// Returns the source cache path used for materialization.
2641    #[must_use]
2642    pub fn source_path(&self) -> &Path {
2643        &self.source_path
2644    }
2645
2646    /// Returns the requested target size namespace.
2647    #[must_use]
2648    pub const fn requested_size(&self) -> ThumbnailSize {
2649        self.requested_size
2650    }
2651
2652    /// Returns the source size namespace.
2653    #[must_use]
2654    pub const fn source_size(&self) -> ThumbnailSize {
2655        self.source_size
2656    }
2657
2658    /// Returns whether this operation wrote a new target file.
2659    #[must_use]
2660    pub const fn written(&self) -> bool {
2661        self.written
2662    }
2663
2664    /// Splits this result into its owned target path, source path, size facts, and write status.
2665    #[must_use]
2666    pub fn into_parts(self) -> MaterializedThumbnailPathParts {
2667        MaterializedThumbnailPathParts {
2668            target_path: self.target_path,
2669            source_path: self.source_path,
2670            requested_size: self.requested_size,
2671            source_size: self.source_size,
2672            written: self.written,
2673        }
2674    }
2675}
2676
2677/// Owned parts of [`MaterializedThumbnailPath`].
2678#[derive(Clone, Debug, Eq, PartialEq)]
2679#[non_exhaustive]
2680pub struct MaterializedThumbnailPathParts {
2681    /// Requested personal-cache target path.
2682    pub target_path: PathBuf,
2683    /// Source cache path used for materialization.
2684    pub source_path: PathBuf,
2685    /// Requested target size namespace.
2686    pub requested_size: ThumbnailSize,
2687    /// Source size namespace.
2688    pub source_size: ThumbnailSize,
2689    /// Whether this operation wrote a new target file.
2690    pub written: bool,
2691}
2692
2693/// PNG bytes result of explicit fallback materialization into the personal cache.
2694///
2695/// Returned bytes are the final target personal-cache PNG bytes, not the source cache bytes.
2696#[derive(Debug, Eq, PartialEq)]
2697pub struct MaterializedThumbnailPngBytes {
2698    target_path: PathBuf,
2699    source_path: PathBuf,
2700    requested_size: ThumbnailSize,
2701    source_size: ThumbnailSize,
2702    written: bool,
2703    bytes: Vec<u8>,
2704}
2705
2706impl MaterializedThumbnailPngBytes {
2707    /// Returns the requested personal-cache target path.
2708    #[must_use]
2709    pub fn target_path(&self) -> &Path {
2710        &self.target_path
2711    }
2712
2713    /// Returns the source cache path used for materialization.
2714    #[must_use]
2715    pub fn source_path(&self) -> &Path {
2716        &self.source_path
2717    }
2718
2719    /// Returns the requested target size namespace.
2720    #[must_use]
2721    pub const fn requested_size(&self) -> ThumbnailSize {
2722        self.requested_size
2723    }
2724
2725    /// Returns the source size namespace.
2726    #[must_use]
2727    pub const fn source_size(&self) -> ThumbnailSize {
2728        self.source_size
2729    }
2730
2731    /// Returns whether this operation wrote a new target file.
2732    #[must_use]
2733    pub const fn written(&self) -> bool {
2734        self.written
2735    }
2736
2737    /// Returns the final target personal-cache PNG bytes.
2738    #[must_use]
2739    pub fn png_bytes(&self) -> &[u8] {
2740        &self.bytes
2741    }
2742
2743    /// Splits this result into its owned target path, source path, size facts, write status, and PNG bytes.
2744    #[must_use]
2745    pub fn into_parts(self) -> MaterializedThumbnailPngBytesParts {
2746        MaterializedThumbnailPngBytesParts {
2747            target_path: self.target_path,
2748            source_path: self.source_path,
2749            requested_size: self.requested_size,
2750            source_size: self.source_size,
2751            written: self.written,
2752            png_bytes: self.bytes,
2753        }
2754    }
2755}
2756
2757/// Owned parts of [`MaterializedThumbnailPngBytes`].
2758#[derive(Debug, Eq, PartialEq)]
2759#[non_exhaustive]
2760pub struct MaterializedThumbnailPngBytesParts {
2761    /// Requested personal-cache target path.
2762    pub target_path: PathBuf,
2763    /// Source cache path used for materialization.
2764    pub source_path: PathBuf,
2765    /// Requested target size namespace.
2766    pub requested_size: ThumbnailSize,
2767    /// Source size namespace.
2768    pub source_size: ThumbnailSize,
2769    /// Whether this operation wrote a new target file.
2770    pub written: bool,
2771    /// Final target personal-cache PNG bytes.
2772    pub png_bytes: Vec<u8>,
2773}
2774
2775/// PNG bytes result of a successful personal-cache install or failure-entry write.
2776///
2777/// The returned bytes are the final PNG bytes published to the cache after metadata writing and
2778/// normalization. Installation metadata is determined from the supplied original facts; callers
2779/// that need to inspect the installed metadata can parse these bytes with [`ParsedThumbnailPng`].
2780#[derive(Debug, Eq, PartialEq)]
2781pub struct InstalledThumbnailPngBytes {
2782    path: PathBuf,
2783    bytes: Vec<u8>,
2784}
2785
2786impl InstalledThumbnailPngBytes {
2787    /// Returns the installed cache path.
2788    #[must_use]
2789    pub fn path(&self) -> &Path {
2790        &self.path
2791    }
2792
2793    /// Returns the final normalized PNG bytes that were installed.
2794    #[must_use]
2795    pub fn png_bytes(&self) -> &[u8] {
2796        &self.bytes
2797    }
2798
2799    /// Splits this result into its owned path and final PNG bytes.
2800    #[must_use]
2801    pub fn into_parts(self) -> InstalledThumbnailPngBytesParts {
2802        InstalledThumbnailPngBytesParts {
2803            path: self.path,
2804            png_bytes: self.bytes,
2805        }
2806    }
2807}
2808
2809/// Owned parts of [`InstalledThumbnailPngBytes`].
2810#[derive(Debug, Eq, PartialEq)]
2811#[non_exhaustive]
2812pub struct InstalledThumbnailPngBytesParts {
2813    /// Installed cache path.
2814    pub path: PathBuf,
2815    /// Final normalized PNG bytes that were installed.
2816    pub png_bytes: Vec<u8>,
2817}
2818
2819fn ensure_private_directory(path: &Path) -> Result<()> {
2820    match fs::symlink_metadata(path) {
2821        Ok(metadata) => {
2822            let problem = if metadata.file_type().is_symlink() {
2823                Some(CacheDirectoryProblem::Symlink)
2824            } else if !metadata.is_dir() {
2825                Some(CacheDirectoryProblem::NotDirectory)
2826            } else if metadata.uid() != rustix::process::getuid().as_raw() {
2827                Some(CacheDirectoryProblem::WrongOwner)
2828            } else if metadata.permissions().mode() & 0o077 != 0 {
2829                Some(CacheDirectoryProblem::GroupOrOtherAccessible)
2830            } else {
2831                None
2832            };
2833            if let Some(problem) = problem {
2834                return Err(ThumbnailError::InsecureCacheDirectory {
2835                    path: path.to_owned(),
2836                    problem,
2837                });
2838            }
2839            Ok(())
2840        }
2841        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2842            if let Some(parent) = path.parent() {
2843                fs::create_dir_all(parent).map_err(|source| {
2844                    ThumbnailError::io(
2845                        "create parent thumbnail cache directories",
2846                        Some(parent.to_owned()),
2847                        source,
2848                    )
2849                })?;
2850            }
2851            match fs::DirBuilder::new().mode(0o700).create(path) {
2852                Ok(()) => {}
2853                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
2854                    return ensure_private_directory(path);
2855                }
2856                Err(source) => {
2857                    return Err(ThumbnailError::io(
2858                        "create thumbnail cache directory",
2859                        Some(path.to_owned()),
2860                        source,
2861                    ));
2862                }
2863            }
2864            fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|source| {
2865                ThumbnailError::io(
2866                    "set thumbnail cache directory permissions",
2867                    Some(path.to_owned()),
2868                    source,
2869                )
2870            })?;
2871            Ok(())
2872        }
2873        Err(source) => Err(ThumbnailError::io(
2874            "inspect thumbnail cache directory",
2875            Some(path.to_owned()),
2876            source,
2877        )),
2878    }
2879}