Skip to main content

markdown_compiler/content/
assets.rs

1use std::fmt;
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
4use thiserror::Error;
5use url::Url;
6
7use super::{AssetDigest, LogicalAssetPath};
8
9macro_rules! canonical_url_wire {
10    ($name:ident) => {
11        impl fmt::Display for $name {
12            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
13                formatter.write_str(self.as_str())
14            }
15        }
16
17        impl Serialize for $name {
18            fn serialize<SerializerType>(
19                &self,
20                serializer: SerializerType,
21            ) -> Result<SerializerType::Ok, SerializerType::Error>
22            where
23                SerializerType: Serializer,
24            {
25                serializer.serialize_str(self.as_str())
26            }
27        }
28
29        impl<'de> Deserialize<'de> for $name {
30            fn deserialize<DeserializerType>(
31                deserializer: DeserializerType,
32            ) -> Result<Self, DeserializerType::Error>
33            where
34                DeserializerType: Deserializer<'de>,
35            {
36                let value = String::deserialize(deserializer)?;
37                Self::parse(&value).map_err(de::Error::custom)
38            }
39        }
40    };
41}
42
43/// A normalized external asset URL that is safe to include in revision input.
44#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
45pub struct ExternalAssetUrl {
46    url: Url,
47    canonical: String,
48}
49
50impl ExternalAssetUrl {
51    pub fn parse(value: &str) -> Result<Self, ExternalAssetUrlError> {
52        if has_forbidden_raw_url_input(value) {
53            return Err(ExternalAssetUrlError);
54        }
55        let value = value.trim();
56        let has_valid_raw_authority = has_valid_raw_authority(value);
57        let mut url = Url::parse(value).map_err(|_| ExternalAssetUrlError)?;
58        if url.scheme() != "https"
59            || url.host().is_none()
60            || !has_valid_raw_authority
61            || !url.username().is_empty()
62            || url.password().is_some()
63            || url.fragment().is_some()
64        {
65            return Err(ExternalAssetUrlError);
66        }
67        url.set_fragment(None);
68        let canonical = url.as_str().to_owned();
69        Ok(Self { url, canonical })
70    }
71
72    pub fn as_url(&self) -> &Url {
73        &self.url
74    }
75
76    pub fn as_str(&self) -> &str {
77        &self.canonical
78    }
79}
80
81canonical_url_wire!(ExternalAssetUrl);
82
83#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
84#[error(
85    "external asset URL must be an absolute HTTPS URL without credentials, a fragment, controls, or backslashes"
86)]
87pub struct ExternalAssetUrlError;
88
89/// A normalized HTTPS origin used by the effective asset allowlist.
90#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
91pub struct ExternalAssetOrigin {
92    url: Url,
93    canonical: String,
94}
95
96impl ExternalAssetOrigin {
97    pub fn parse(value: &str) -> Result<Self, ExternalAssetOriginError> {
98        if has_forbidden_raw_url_input(value) {
99            return Err(ExternalAssetOriginError);
100        }
101        let value = value.trim();
102        let has_valid_raw_authority = has_valid_raw_authority(value);
103        let has_valid_raw_suffix = has_root_only_raw_suffix(value);
104        let mut url = Url::parse(value).map_err(|_| ExternalAssetOriginError)?;
105        if url.scheme() != "https"
106            || url.host().is_none()
107            || !has_valid_raw_authority
108            || !has_valid_raw_suffix
109            || !url.username().is_empty()
110            || url.password().is_some()
111            || url.query().is_some()
112            || url.fragment().is_some()
113            || !matches!(url.path(), "" | "/")
114        {
115            return Err(ExternalAssetOriginError);
116        }
117        url.set_path("/");
118        let canonical = url.as_str().to_owned();
119        if canonical.contains([';', '\'', '*', ',']) {
120            return Err(ExternalAssetOriginError);
121        }
122        Ok(Self { url, canonical })
123    }
124
125    pub fn as_url(&self) -> &Url {
126        &self.url
127    }
128
129    pub fn as_str(&self) -> &str {
130        &self.canonical
131    }
132}
133
134canonical_url_wire!(ExternalAssetOrigin);
135
136#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
137#[error(
138    "asset origin must be an absolute HTTPS origin without credentials, path, query, fragment, controls, or backslashes"
139)]
140pub struct ExternalAssetOriginError;
141
142/// A local asset path paired with the digest of its exact bytes.
143#[derive(Clone, Debug, Eq, PartialEq)]
144pub struct DigestedAsset {
145    pub path: LogicalAssetPath,
146    pub digest: AssetDigest,
147}
148
149impl DigestedAsset {
150    pub const fn new(path: LogicalAssetPath, digest: AssetDigest) -> Self {
151        Self { path, digest }
152    }
153}
154
155/// A normalized local or external asset reference used by digest transcripts.
156#[derive(Clone, Debug, Eq, PartialEq)]
157pub enum AssetRevisionReference {
158    Local(DigestedAsset),
159    External(ExternalAssetUrl),
160}
161
162impl AssetRevisionReference {
163    pub const fn local(asset: DigestedAsset) -> Self {
164        Self::Local(asset)
165    }
166
167    pub const fn external(url: ExternalAssetUrl) -> Self {
168        Self::External(url)
169    }
170
171    pub(crate) fn sort_key(&self) -> (u8, &str) {
172        match self {
173            Self::Local(asset) => (0, asset.path.as_str()),
174            Self::External(url) => (1, url.as_str()),
175        }
176    }
177}
178
179fn has_valid_raw_authority(value: &str) -> bool {
180    let Some((_, remainder)) = value.split_once("://") else {
181        return false;
182    };
183    let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
184    let authority = &remainder[..authority_end];
185    !authority.is_empty() && !authority.contains('@') && !authority.ends_with(':')
186}
187
188fn has_forbidden_raw_url_input(value: &str) -> bool {
189    value.contains('\\') || value.chars().any(char::is_control)
190}
191
192fn has_root_only_raw_suffix(value: &str) -> bool {
193    value.split_once("://").is_some_and(|(_, remainder)| {
194        let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
195        matches!(&remainder[authority_end..], "" | "/")
196    })
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn external_asset_urls_are_normalized_and_bounded() {
205        let url = ExternalAssetUrl::parse("https://EXAMPLE.com:443/image.png?version=1").unwrap();
206        assert_eq!(url.as_str(), "https://example.com/image.png?version=1");
207
208        for invalid in [
209            "http://example.com/image.png",
210            "https://user@example.com/image.png",
211            "https://@example.com/image.png",
212            "HTTPS://@example.com/image.png",
213            "https://example.com:/image.png",
214            "https://example.com:invalid/image.png",
215            "https://example.com:65536/image.png",
216            "https://example.com/image.png#fragment",
217            "https://example.com\\evil.png",
218            "https://exa\nmple.com/image.png",
219            "https://exa\tmple.com/image.png",
220            "/image.png",
221        ] {
222            assert_eq!(ExternalAssetUrl::parse(invalid), Err(ExternalAssetUrlError));
223            assert!(
224                serde_json::from_value::<ExternalAssetUrl>(serde_json::json!(invalid)).is_err()
225            );
226        }
227    }
228
229    #[test]
230    fn external_asset_origins_have_an_exact_normalized_boundary() {
231        let origin = ExternalAssetOrigin::parse("HTTPS://EXAMPLE.com:443").unwrap();
232        assert_eq!(origin.as_str(), "https://example.com/");
233
234        for invalid in [
235            "http://example.com",
236            "https://@example.com",
237            "https://example.com:",
238            "https://example.com:invalid",
239            "https://example.com:65536",
240            "https://example.com/path",
241            "https://example.com/foo/..",
242            "https://example.com/%2e",
243            "https://example.com/?query=1",
244            "https://example.com/#fragment",
245            "https://example.com;script-src",
246            "https://example.com%3Bscript-src",
247            "https://example.com'",
248            "https://*.example.com",
249            "https://example.com,evil.example",
250            "https://example.com\\path",
251            "https://exa\nmple.com",
252            "https://exa\tmple.com",
253        ] {
254            assert_eq!(
255                ExternalAssetOrigin::parse(invalid),
256                Err(ExternalAssetOriginError)
257            );
258        }
259    }
260}