markdown_compiler/content/
assets.rs1use 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#[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#[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#[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#[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}