1use std::fmt;
5use std::os::unix::ffi::OsStrExt;
6use std::path::Path;
7use std::str::FromStr;
8
9use crate::{Result, ThumbnailError};
10
11#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
13pub struct PersonalOriginalUri {
14 value: String,
15}
16
17impl PersonalOriginalUri {
18 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 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 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 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 #[must_use]
132 pub fn as_str(&self) -> &str {
133 &self.value
134 }
135
136 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
157pub struct SharedRelativeOriginalUri {
158 value: String,
159}
160
161impl SharedRelativeOriginalUri {
162 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 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 #[must_use]
203 pub fn as_str(&self) -> &str {
204 &self.value
205 }
206
207 #[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}