Skip to main content

xdg_thumbnail/
uri.rs

1// SPDX-FileCopyrightText: 2026 KIM Hyunjae
2// SPDX-License-Identifier: MPL-2.0
3
4use std::fmt;
5use std::os::unix::ffi::OsStrExt;
6use std::path::Path;
7use std::str::FromStr;
8
9use crate::{Result, ThumbnailError};
10
11/// A canonical absolute URI identity for entries in the personal thumbnail cache.
12#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
13pub struct PersonalOriginalUri {
14    value: String,
15}
16
17impl PersonalOriginalUri {
18    /// Constructs a canonical `file:///` URI from an absolute local path.
19    ///
20    /// This constructor preserves Unix path bytes, performs byte-level percent-encoding, and never
21    /// expands shell syntax or resolves symlinks.
22    ///
23    /// # Errors
24    ///
25    /// Returns an error when the path is not absolute or contains bytes that cannot be represented
26    /// as a local thumbnail URI identity.
27    pub fn from_absolute_path(path: impl AsRef<Path>) -> Result<Self> {
28        Self::from_absolute_path_bytes(path.as_ref().as_os_str().as_bytes())
29    }
30
31    /// Constructs a canonical `file:///` URI from absolute Unix path bytes.
32    ///
33    /// This is the low-level byte-preserving constructor for callers that already have raw Unix
34    /// path bytes. Most callers should use [`Self::from_absolute_path`].
35    ///
36    /// # Errors
37    ///
38    /// Returns an error when the path bytes are not absolute or contain NUL.
39    pub fn from_absolute_path_bytes(path: &[u8]) -> Result<Self> {
40        if !path.starts_with(b"/") {
41            return Err(ThumbnailError::invalid_uri("local path must be absolute"));
42        }
43        if path.contains(&0) {
44            return Err(ThumbnailError::invalid_uri(
45                "local path must not contain NUL",
46            ));
47        }
48
49        Ok(Self {
50            value: format!("file://{}", encode_uri_path_bytes(path, true)),
51        })
52    }
53
54    /// Accepts textual local `file:` URI input and normalizes local file URI casing.
55    ///
56    /// # Errors
57    ///
58    /// Returns an error when the input is not an ASCII absolute local `file:` URI, has a non-local
59    /// authority, or contains invalid percent escapes or decoded path bytes.
60    pub fn from_local_file_uri(uri: &str) -> Result<Self> {
61        validate_ascii_uri_identity(uri)?;
62        let scheme_end = uri
63            .find(':')
64            .ok_or_else(|| ThumbnailError::invalid_uri("local URI must use the file scheme"))?;
65        let scheme = &uri[..scheme_end];
66        validate_scheme(scheme)?;
67        if !scheme.eq_ignore_ascii_case("file") {
68            return Err(ThumbnailError::invalid_uri(
69                "local URI must use the file scheme",
70            ));
71        }
72        let rest = &uri[scheme_end + 1..];
73
74        let path = if let Some(rest) = rest.strip_prefix("//") {
75            let (authority, path) = rest
76                .split_once('/')
77                .ok_or_else(|| ThumbnailError::invalid_uri("file URI path must be absolute"))?;
78            if !(authority.is_empty() || authority.eq_ignore_ascii_case("localhost")) {
79                return Err(ThumbnailError::invalid_uri(
80                    "file URI authority is not directly local",
81                ));
82            }
83            format!("/{path}")
84        } else if rest.starts_with('/') {
85            rest.to_owned()
86        } else {
87            return Err(ThumbnailError::invalid_uri(
88                "file URI path must be absolute",
89            ));
90        };
91        if !path.starts_with('/') {
92            return Err(ThumbnailError::invalid_uri(
93                "file URI path must be absolute",
94            ));
95        }
96        validate_uri_path_text(path.as_bytes(), true)?;
97        let path_bytes = percent_decode_bytes(path.as_bytes())?;
98        Self::from_absolute_path_bytes(&path_bytes)
99    }
100
101    /// Accepts caller-selected non-file absolute thumbnail URI identity text and preserves it exactly.
102    ///
103    /// This validates that the text can be used as a thumbnail URI identity. It does not promise
104    /// full RFC URI parsing or scheme-specific normalization.
105    ///
106    /// # Errors
107    ///
108    /// Returns an error when the URI is relative, not ASCII percent-encoded identity text, invalid
109    /// as absolute thumbnail URI identity text, or uses the local `file:` scheme.
110    pub fn from_non_file_uri(uri: &str) -> Result<Self> {
111        let scheme = validate_absolute_uri_identity(uri)?;
112        if scheme.eq_ignore_ascii_case("file") {
113            return Err(ThumbnailError::invalid_uri(
114                "non-file URI identity must not use the file scheme",
115            ));
116        }
117
118        Ok(Self {
119            value: uri.to_owned(),
120        })
121    }
122
123    pub(crate) fn from_validated_absolute_uri(uri: &str) -> Result<Self> {
124        validate_absolute_uri_identity(uri)?;
125        Ok(Self {
126            value: uri.to_owned(),
127        })
128    }
129
130    /// Returns the canonical URI identity string.
131    #[must_use]
132    pub fn as_str(&self) -> &str {
133        &self.value
134    }
135
136    /// Returns the Freedesktop thumbnail filename for this URI identity.
137    #[must_use]
138    pub fn thumbnail_file_name(&self) -> String {
139        format!("{}.png", md5_stem(self.value.as_bytes()))
140    }
141}
142
143impl fmt::Display for PersonalOriginalUri {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        f.write_str(&self.value)
146    }
147}
148
149impl AsRef<str> for PersonalOriginalUri {
150    fn as_ref(&self) -> &str {
151        self.as_str()
152    }
153}
154
155/// A canonical `./`-prefixed URI identity for direct children in shared repositories.
156#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
157pub struct SharedRelativeOriginalUri {
158    value: String,
159}
160
161impl SharedRelativeOriginalUri {
162    /// Constructs a shared URI from one raw direct child filename.
163    ///
164    /// # Errors
165    ///
166    /// Returns an error when the filename is empty, is `.` or `..`, contains `/`, or contains NUL.
167    pub fn from_raw_child_name(name: &[u8]) -> Result<Self> {
168        validate_raw_shared_child_name(name)?;
169
170        Ok(Self {
171            value: format!("./{}", encode_uri_path_bytes(name, false)),
172        })
173    }
174
175    /// Parses textual `./` shared URI input without allowing encoded path separators.
176    ///
177    /// # Errors
178    ///
179    /// Returns an error when the URI is not ASCII `./` text for exactly one direct child filename,
180    /// contains invalid percent escapes, or decodes to an invalid child filename.
181    pub fn parse(uri: &str) -> Result<Self> {
182        validate_ascii_uri_identity(uri)?;
183        let encoded = uri
184            .strip_prefix("./")
185            .ok_or_else(|| ThumbnailError::invalid_uri("shared URI must start with ./"))?;
186        if encoded.is_empty() {
187            return Err(ThumbnailError::invalid_uri(
188                "shared URI child name must not be empty",
189            ));
190        }
191        if encoded.contains('/') {
192            return Err(ThumbnailError::invalid_uri(
193                "shared URI must name one direct child",
194            ));
195        }
196        validate_uri_path_text(encoded.as_bytes(), false)?;
197        let decoded = percent_decode_bytes(encoded.as_bytes())?;
198        Self::from_raw_child_name(&decoded)
199    }
200
201    /// Returns the canonical shared relative URI identity string.
202    #[must_use]
203    pub fn as_str(&self) -> &str {
204        &self.value
205    }
206
207    /// Returns the Freedesktop thumbnail filename for this URI identity.
208    #[must_use]
209    pub fn thumbnail_file_name(&self) -> String {
210        format!("{}.png", md5_stem(self.value.as_bytes()))
211    }
212}
213
214impl fmt::Display for SharedRelativeOriginalUri {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        f.write_str(&self.value)
217    }
218}
219
220impl AsRef<str> for SharedRelativeOriginalUri {
221    fn as_ref(&self) -> &str {
222        self.as_str()
223    }
224}
225
226impl FromStr for SharedRelativeOriginalUri {
227    type Err = ThumbnailError;
228
229    fn from_str(value: &str) -> Result<Self> {
230        Self::parse(value)
231    }
232}
233
234fn md5_stem(input: &[u8]) -> String {
235    format!("{:x}", md5::compute(input))
236}
237
238pub(crate) fn validate_absolute_uri_identity(uri: &str) -> Result<&str> {
239    validate_ascii_uri_identity(uri)?;
240    let scheme_end = uri
241        .find(':')
242        .ok_or_else(|| ThumbnailError::invalid_uri("URI must be absolute"))?;
243    let scheme = &uri[..scheme_end];
244    validate_scheme(scheme)?;
245    validate_percent_escapes(uri.as_bytes())?;
246    Ok(scheme)
247}
248
249fn validate_raw_shared_child_name(name: &[u8]) -> Result<()> {
250    if name.is_empty() {
251        return Err(ThumbnailError::invalid_uri(
252            "shared child name must not be empty",
253        ));
254    }
255    if name == b"." || name == b".." {
256        return Err(ThumbnailError::invalid_uri(
257            "shared child name must not be . or ..",
258        ));
259    }
260    if name.contains(&b'/') || name.contains(&0) {
261        return Err(ThumbnailError::invalid_uri(
262            "shared child name must be one path segment",
263        ));
264    }
265    Ok(())
266}
267
268fn validate_scheme(scheme: &str) -> Result<()> {
269    let mut bytes = scheme.bytes();
270    let Some(first) = bytes.next() else {
271        return Err(ThumbnailError::invalid_uri("URI scheme must not be empty"));
272    };
273    if !first.is_ascii_alphabetic() {
274        return Err(ThumbnailError::invalid_uri(
275            "URI scheme must start with an ASCII letter",
276        ));
277    }
278    if !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.')) {
279        return Err(ThumbnailError::invalid_uri(
280            "URI scheme contains an invalid character",
281        ));
282    }
283    Ok(())
284}
285
286fn validate_ascii_uri_identity(uri: &str) -> Result<()> {
287    if uri.is_empty() {
288        return Err(ThumbnailError::invalid_uri("URI must not be empty"));
289    }
290    if !uri.is_ascii() {
291        return Err(ThumbnailError::invalid_uri(
292            "URI identity must be ASCII and percent-encoded",
293        ));
294    }
295    if uri
296        .bytes()
297        .any(|byte| byte.is_ascii_control() || byte == b' ')
298    {
299        return Err(ThumbnailError::invalid_uri(
300            "URI identity must not contain control characters or spaces",
301        ));
302    }
303    Ok(())
304}
305
306fn validate_uri_path_text(input: &[u8], allow_slash: bool) -> Result<()> {
307    let mut i = 0;
308    while i < input.len() {
309        if input[i] == b'%' {
310            if i + 2 >= input.len()
311                || !input[i + 1].is_ascii_hexdigit()
312                || !input[i + 2].is_ascii_hexdigit()
313            {
314                return Err(ThumbnailError::invalid_uri(
315                    "URI contains an invalid percent escape",
316                ));
317            }
318            i += 3;
319        } else if is_safe_path_byte(input[i], allow_slash) {
320            i += 1;
321        } else {
322            return Err(ThumbnailError::invalid_uri(
323                "URI path contains an unescaped byte that must be percent-encoded",
324            ));
325        }
326    }
327    Ok(())
328}
329
330fn validate_percent_escapes(input: &[u8]) -> Result<()> {
331    let mut i = 0;
332    while i < input.len() {
333        if input[i] == b'%' {
334            if i + 2 >= input.len()
335                || !input[i + 1].is_ascii_hexdigit()
336                || !input[i + 2].is_ascii_hexdigit()
337            {
338                return Err(ThumbnailError::invalid_uri(
339                    "URI contains an invalid percent escape",
340                ));
341            }
342            i += 3;
343        } else {
344            i += 1;
345        }
346    }
347    Ok(())
348}
349
350fn percent_decode_bytes(input: &[u8]) -> Result<Vec<u8>> {
351    validate_percent_escapes(input)?;
352    let mut output = Vec::with_capacity(input.len());
353    let mut i = 0;
354    while i < input.len() {
355        if input[i] == b'%' {
356            let high = hex_value(input[i + 1]).ok_or_else(|| {
357                ThumbnailError::invalid_uri("URI contains an invalid percent escape")
358            })?;
359            let low = hex_value(input[i + 2]).ok_or_else(|| {
360                ThumbnailError::invalid_uri("URI contains an invalid percent escape")
361            })?;
362            output.push(high << 4 | low);
363            i += 3;
364        } else {
365            output.push(input[i]);
366            i += 1;
367        }
368    }
369    Ok(output)
370}
371
372fn hex_value(byte: u8) -> Option<u8> {
373    match byte {
374        b'0'..=b'9' => Some(byte - b'0'),
375        b'a'..=b'f' => Some(byte - b'a' + 10),
376        b'A'..=b'F' => Some(byte - b'A' + 10),
377        _ => None,
378    }
379}
380
381fn encode_uri_path_bytes(bytes: &[u8], allow_slash: bool) -> String {
382    let mut encoded = String::with_capacity(bytes.len());
383    for &byte in bytes {
384        if is_safe_path_byte(byte, allow_slash) {
385            encoded.push(char::from(byte));
386        } else {
387            encoded.push_str(&format!("%{byte:02X}"));
388        }
389    }
390    encoded
391}
392
393fn is_safe_path_byte(byte: u8, allow_slash: bool) -> bool {
394    byte.is_ascii_alphanumeric()
395        || (allow_slash && byte == b'/')
396        || matches!(
397            byte,
398            b'-' | b'.'
399                | b'_'
400                | b'~'
401                | b'!'
402                | b'$'
403                | b'&'
404                | b'\''
405                | b'('
406                | b')'
407                | b'*'
408                | b'+'
409                | b','
410                | b';'
411                | b'='
412                | b':'
413                | b'@'
414        )
415}