Skip to main content

xdg_thumbnail/
identity.rs

1// SPDX-FileCopyrightText: 2026 KIM Hyunjae
2// SPDX-License-Identifier: MPL-2.0
3
4use std::ffi::{OsStr, OsString};
5use std::fmt;
6use std::fs::File;
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use std::os::unix::ffi::OsStrExt;
11
12use crate::{
13    CacheRootProblem, PersonalOriginalUri, Result, SharedRelativeOriginalUri, ThumbnailError,
14    ThumbnailSize, validate_mime_type,
15};
16
17/// Whole Unix epoch seconds used by `Thumb::MTime`.
18#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
19pub struct UnixMtimeSeconds {
20    seconds: u64,
21}
22
23impl UnixMtimeSeconds {
24    /// Creates a timestamp from non-negative whole Unix epoch seconds.
25    #[must_use]
26    pub const fn new(seconds: u64) -> Self {
27        Self { seconds }
28    }
29
30    /// Creates a timestamp from signed whole Unix epoch seconds.
31    ///
32    /// # Errors
33    ///
34    /// Returns an error when `seconds` is negative.
35    pub const fn try_from_i64(seconds: i64) -> Result<Self> {
36        if seconds < 0 {
37            return Err(ThumbnailError::invalid_metadata(
38                "mtime is before the Unix epoch",
39            ));
40        }
41        Ok(Self {
42            seconds: seconds as u64,
43        })
44    }
45
46    /// Converts a [`SystemTime`] to whole non-negative Unix epoch seconds.
47    ///
48    /// # Errors
49    ///
50    /// Returns an error when `time` is before the Unix epoch.
51    pub fn from_system_time(time: SystemTime) -> Result<Self> {
52        let duration = time
53            .duration_since(UNIX_EPOCH)
54            .map_err(|_| ThumbnailError::invalid_metadata("mtime is before the Unix epoch"))?;
55        Ok(Self {
56            seconds: duration.as_secs(),
57        })
58    }
59
60    /// Returns whole Unix epoch seconds.
61    #[must_use]
62    pub const fn as_u64(self) -> u64 {
63        self.seconds
64    }
65}
66
67impl TryFrom<i64> for UnixMtimeSeconds {
68    type Error = ThumbnailError;
69
70    fn try_from(seconds: i64) -> Result<Self> {
71        Self::try_from_i64(seconds)
72    }
73}
74
75impl From<u64> for UnixMtimeSeconds {
76    fn from(seconds: u64) -> Self {
77        Self::new(seconds)
78    }
79}
80
81impl From<UnixMtimeSeconds> for u64 {
82    fn from(mtime: UnixMtimeSeconds) -> Self {
83        mtime.as_u64()
84    }
85}
86
87impl TryFrom<SystemTime> for UnixMtimeSeconds {
88    type Error = ThumbnailError;
89
90    fn try_from(time: SystemTime) -> Result<Self> {
91        Self::from_system_time(time)
92    }
93}
94
95impl fmt::Display for UnixMtimeSeconds {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        write!(f, "{}", self.seconds)
98    }
99}
100
101/// Canonical personal URI plus original freshness and write facts needed for validation and writes.
102///
103/// This is not a URI-only identity. It carries the `Thumb::URI` value together with the
104/// modification time required for freshness checks and optional original size and MIME facts used
105/// when writing thumbnail metadata.
106#[derive(Clone, Debug, Eq, PartialEq)]
107pub struct PersonalOriginalIdentity {
108    uri: PersonalOriginalUri,
109    mtime: UnixMtimeSeconds,
110    original_byte_size: Option<u64>,
111    mime_type: Option<String>,
112}
113
114impl PersonalOriginalIdentity {
115    /// Creates an original identity from caller-confirmed facts without a MIME type.
116    #[must_use]
117    pub fn new(uri: PersonalOriginalUri, mtime: UnixMtimeSeconds) -> Self {
118        Self {
119            uri,
120            mtime,
121            original_byte_size: None,
122            mime_type: None,
123        }
124    }
125
126    /// Adds the original byte size.
127    #[must_use]
128    pub fn with_original_byte_size(mut self, size: u64) -> Self {
129        self.original_byte_size = Some(size);
130        self
131    }
132
133    /// Adds a MIME type.
134    ///
135    /// # Errors
136    ///
137    /// Returns an error when `mime_type` is not valid thumbnail metadata.
138    pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Result<Self> {
139        let mime_type = mime_type.into();
140        validate_mime_type(&mime_type)?;
141        self.mime_type = Some(mime_type);
142        Ok(self)
143    }
144
145    /// Returns the canonical personal-cache URI.
146    #[must_use]
147    pub fn uri(&self) -> &PersonalOriginalUri {
148        &self.uri
149    }
150
151    /// Returns the original modification time.
152    #[must_use]
153    pub const fn mtime(&self) -> UnixMtimeSeconds {
154        self.mtime
155    }
156
157    /// Returns the original byte size when known.
158    #[must_use]
159    pub const fn original_byte_size(&self) -> Option<u64> {
160        self.original_byte_size
161    }
162
163    /// Returns the original MIME type when known.
164    #[must_use]
165    pub fn mime_type(&self) -> Option<&str> {
166        self.mime_type.as_deref()
167    }
168}
169
170/// An original identity whose source has been confirmed readable by the caller.
171#[derive(Clone, Debug, Eq, PartialEq)]
172pub struct ReadablePersonalOriginalIdentity {
173    identity: PersonalOriginalIdentity,
174}
175
176impl ReadablePersonalOriginalIdentity {
177    /// Records caller- or backend-confirmed original identity facts as readable.
178    ///
179    /// This performs no I/O. Callers use it to attest that the supplied identity describes an
180    /// original that was readable through their chosen storage backend.
181    #[must_use]
182    pub fn assume_readable(identity: PersonalOriginalIdentity) -> Self {
183        Self { identity }
184    }
185
186    /// Consumes this readability wrapper and returns the underlying identity facts.
187    #[must_use]
188    pub fn into_identity(self) -> PersonalOriginalIdentity {
189        self.identity
190    }
191
192    /// Opens a local original for reading and derives its identity facts.
193    ///
194    /// This performs blocking filesystem I/O. Async applications should call it from a blocking
195    /// adapter rather than directly on an async executor worker.
196    ///
197    /// The cache URI identity is built from the caller-supplied absolute path bytes without
198    /// resolving symlinks. Readability, modification time, and size are taken from opening and
199    /// statting the referenced file target.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error when the path is not absolute, the original cannot be opened for reading,
204    /// metadata or modification time cannot be read, or the path cannot form a canonical local URI.
205    pub fn from_local_path(path: impl AsRef<Path>) -> Result<Self> {
206        Self::from_local_path_inner(path.as_ref(), None)
207    }
208
209    /// Opens a local original for reading and derives its identity facts with a MIME type.
210    ///
211    /// This performs blocking filesystem I/O. Async applications should call it from a blocking
212    /// adapter rather than directly on an async executor worker.
213    ///
214    /// The cache URI identity is built from the caller-supplied absolute path bytes without
215    /// resolving symlinks. Readability, modification time, and size are taken from opening and
216    /// statting the referenced file target.
217    ///
218    /// # Errors
219    ///
220    /// Returns the same errors as [`Self::from_local_path`] and also rejects invalid MIME type
221    /// metadata.
222    pub fn from_local_path_with_mime_type(
223        path: impl AsRef<Path>,
224        mime_type: impl Into<String>,
225    ) -> Result<Self> {
226        Self::from_local_path_inner(path.as_ref(), Some(mime_type.into()))
227    }
228
229    fn from_local_path_inner(path: &Path, mime_type: Option<String>) -> Result<Self> {
230        if !path.is_absolute() {
231            return Err(ThumbnailError::invalid_uri("local path must be absolute"));
232        }
233        let file = File::open(path).map_err(|source| {
234            ThumbnailError::io("open original for reading", Some(path.to_owned()), source)
235        })?;
236        let metadata = file.metadata().map_err(|source| {
237            ThumbnailError::io("read original metadata", Some(path.to_owned()), source)
238        })?;
239        let uri = PersonalOriginalUri::from_absolute_path_bytes(path.as_os_str().as_bytes())?;
240        let mtime = UnixMtimeSeconds::from_system_time(metadata.modified().map_err(|source| {
241            ThumbnailError::io(
242                "read original modification time",
243                Some(path.to_owned()),
244                source,
245            )
246        })?)?;
247        let identity =
248            PersonalOriginalIdentity::new(uri, mtime).with_original_byte_size(metadata.len());
249        let identity = if let Some(mime_type) = mime_type {
250            identity.with_mime_type(mime_type)?
251        } else {
252            identity
253        };
254        Ok(Self { identity })
255    }
256
257    /// Returns the readable identity facts.
258    #[must_use]
259    pub const fn identity(&self) -> &PersonalOriginalIdentity {
260        &self.identity
261    }
262}
263
264/// Explicit context for read-only shared repository lookup.
265#[derive(Clone, Debug, Eq, PartialEq)]
266pub struct SharedRepositoryContext {
267    repository_root: PathBuf,
268    original_child_name: OsString,
269    shared_uri: SharedRelativeOriginalUri,
270}
271
272impl SharedRepositoryContext {
273    /// Creates a shared repository context for one direct child of `repository_root`.
274    ///
275    /// # Errors
276    ///
277    /// Returns an error when `repository_root` is not absolute or `original_child_name` cannot be
278    /// represented as a shared direct-child URI identity.
279    pub fn new(
280        repository_root: impl AsRef<Path>,
281        original_child_name: impl AsRef<OsStr>,
282    ) -> Result<Self> {
283        let repository_root = repository_root.as_ref();
284        let original_child_name = original_child_name.as_ref();
285        if !repository_root.is_absolute() {
286            return Err(ThumbnailError::InvalidCacheRoot {
287                path: repository_root.to_owned(),
288                problem: CacheRootProblem::NotAbsolute,
289            });
290        }
291        let shared_uri =
292            SharedRelativeOriginalUri::from_raw_child_name(original_child_name.as_bytes())?;
293        Ok(Self {
294            repository_root: repository_root.to_owned(),
295            original_child_name: original_child_name.to_owned(),
296            shared_uri,
297        })
298    }
299
300    /// Returns the shared repository root directory.
301    #[must_use]
302    pub fn repository_root(&self) -> &Path {
303        &self.repository_root
304    }
305
306    /// Returns the direct child original filename.
307    #[must_use]
308    pub fn original_child_name(&self) -> &OsStr {
309        &self.original_child_name
310    }
311
312    /// Returns the shared URI used for hashing and optional metadata comparison.
313    #[must_use]
314    pub const fn shared_uri(&self) -> &SharedRelativeOriginalUri {
315        &self.shared_uri
316    }
317
318    /// Computes the shared repository cache entry path for a successful thumbnail size.
319    #[must_use]
320    pub fn cache_entry_path(&self, size: ThumbnailSize) -> PathBuf {
321        self.repository_root
322            .join(".sh_thumbnails")
323            .join(size.directory_name())
324            .join(self.shared_uri.thumbnail_file_name())
325    }
326}