markdown_compiler/content/
path.rs1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
7#[serde(transparent)]
8pub struct LogicalAssetPath(String);
9
10impl LogicalAssetPath {
11 pub fn parse(value: &str) -> Result<Self, LogicalTreePathError> {
12 validate_portable_path(value)?;
13 let mut components = value.split('/');
14 if components.next() != Some("assets") || components.next().is_none() {
15 return Err(LogicalTreePathError::WrongAssetNamespace);
16 }
17 Ok(Self(value.to_owned()))
18 }
19
20 pub fn as_str(&self) -> &str {
21 &self.0
22 }
23}
24
25impl fmt::Display for LogicalAssetPath {
26 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
27 formatter.write_str(self.as_str())
28 }
29}
30
31#[derive(Clone, Copy, Debug, Deserialize, Eq, Error, PartialEq, Serialize)]
32#[serde(rename_all = "snake_case")]
33pub enum LogicalTreePathError {
34 #[error("logical path must not be empty")]
35 Empty,
36 #[error("logical path must be relative")]
37 Absolute,
38 #[error("logical path contains an unsupported component")]
39 UnsupportedComponent,
40 #[error("logical path contains an encoded or platform-specific separator")]
41 EncodedTraversal,
42 #[error("logical path exceeds its byte limit")]
43 TooLong,
44 #[error("logical asset path must start with assets/ and name an entry")]
45 WrongAssetNamespace,
46}
47
48fn validate_portable_path(value: &str) -> Result<(), LogicalTreePathError> {
49 if value.is_empty() {
50 return Err(LogicalTreePathError::Empty);
51 }
52 if value.starts_with('/') || value.starts_with('\\') {
53 return Err(LogicalTreePathError::Absolute);
54 }
55 if value.contains(['%', '\\']) {
56 return Err(LogicalTreePathError::EncodedTraversal);
57 }
58 if !value.split('/').all(is_portable_component) {
59 return Err(LogicalTreePathError::UnsupportedComponent);
60 }
61 Ok(())
62}
63
64fn is_portable_component(component: &str) -> bool {
65 !component.is_empty()
66 && component != "."
67 && component != ".."
68 && component
69 .bytes()
70 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
71}