webui_parser/
asset_filename.rs1use sha2::{Digest, Sha256};
7use std::fmt::Write as _;
8use thiserror::Error;
9
10pub const DEFAULT_ASSET_FILE_NAME_TEMPLATE: &str = "[name].[ext]";
12
13#[derive(Debug, Clone, PartialEq, Eq, Error)]
15#[error("{message}")]
16pub struct AssetFileNameTemplateError {
17 message: String,
18}
19
20impl AssetFileNameTemplateError {
21 fn new(message: String) -> Self {
22 Self { message }
23 }
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct AssetFileNameTemplate {
29 file_name_template: String,
30 uses_hash: bool,
31}
32
33impl AssetFileNameTemplate {
34 pub fn try_new(
43 file_name_template: String,
44 option_name: &str,
45 ) -> Result<Self, AssetFileNameTemplateError> {
46 validate_template(&file_name_template, option_name)?;
47 Ok(Self {
48 uses_hash: file_name_template.contains("[hash]"),
49 file_name_template,
50 })
51 }
52
53 pub(crate) fn new_unchecked(file_name_template: String) -> Self {
54 Self {
55 uses_hash: file_name_template.contains("[hash]"),
56 file_name_template,
57 }
58 }
59
60 #[must_use]
64 pub fn resolve(&self, name: &str, ext: &str, content: &[u8]) -> String {
65 let safe_name = name.replace(['/', '\\'], "-");
66 let hash = if self.uses_hash {
67 short_sha256_hex(content)
68 } else {
69 String::new()
70 };
71 format_asset_filename(&self.file_name_template, &safe_name, ext, &hash)
72 }
73
74 #[must_use]
76 pub fn file_name_template(&self) -> &str {
77 &self.file_name_template
78 }
79
80 #[must_use]
82 pub fn uses_hash(&self) -> bool {
83 self.uses_hash
84 }
85}
86
87fn validate_template(template: &str, option_name: &str) -> Result<(), AssetFileNameTemplateError> {
88 if template.is_empty() {
89 return Err(AssetFileNameTemplateError::new(format!(
90 "{option_name} cannot be empty"
91 )));
92 }
93 if contains_invalid_filename_template_byte(template) {
94 return Err(AssetFileNameTemplateError::new(format!(
95 "{option_name} must be ASCII and cannot contain path separators, URL-significant or Windows-reserved filename characters, control characters, or whitespace"
96 )));
97 }
98 if template.contains("..") {
99 return Err(AssetFileNameTemplateError::new(format!(
100 "{option_name} cannot contain '..'"
101 )));
102 }
103 let bytes = template.as_bytes();
104 let mut i = 0usize;
105 while i < bytes.len() {
106 if bytes[i] == b'[' {
107 let start = i + 1;
108 let mut j = start;
109 while j < bytes.len() && bytes[j] != b']' {
110 j += 1;
111 }
112 if j >= bytes.len() {
113 return Err(AssetFileNameTemplateError::new(format!(
114 "Invalid {option_name} '{template}': missing closing ']'"
115 )));
116 }
117 let token = &template[start..j];
118 match token {
119 "name" | "hash" | "ext" => {}
120 _ => {
121 return Err(AssetFileNameTemplateError::new(format!(
122 "Invalid {option_name} '{template}': unknown token '[{token}]'. Allowed tokens: [name], [hash], [ext]"
123 )));
124 }
125 }
126 i = j + 1;
127 continue;
128 }
129 i += 1;
130 }
131
132 Ok(())
133}
134
135fn format_asset_filename(template: &str, name: &str, ext: &str, hash: &str) -> String {
136 debug_assert!(template.is_ascii());
137 let mut out = String::with_capacity(template.len() + name.len() + ext.len() + 16);
138 let bytes = template.as_bytes();
139 let mut i = 0usize;
140 while i < bytes.len() {
141 if bytes[i] == b'[' {
142 let start = i + 1;
143 let mut j = start;
144 while j < bytes.len() && bytes[j] != b']' {
145 j += 1;
146 }
147 let token = &template[start..j];
148 match token {
149 "name" => out.push_str(name),
150 "hash" => out.push_str(hash),
151 "ext" => out.push_str(ext),
152 _ => {}
153 }
154 i = j + 1;
155 continue;
156 }
157
158 out.push(char::from(bytes[i]));
159 i += 1;
160 }
161 out
162}
163
164fn short_sha256_hex(content: &[u8]) -> String {
165 let digest = Sha256::digest(content);
166 let mut out = String::with_capacity(8);
167 for b in &digest[..4] {
168 let _ = write!(&mut out, "{b:02x}");
169 }
170 out
171}
172
173fn contains_invalid_filename_template_byte(value: &str) -> bool {
174 !value.is_ascii()
175 || value.bytes().any(|b| {
176 matches!(
177 b,
178 0x00..=0x1F
179 | b'"'
180 | b'#'
181 | b'%'
182 | b'*'
183 | b'/'
184 | b':'
185 | b'<'
186 | b'>'
187 | b'?'
188 | b'\\'
189 | b'|'
190 ) || b.is_ascii_whitespace()
191 })
192}
193
194#[cfg(test)]
195#[allow(clippy::disallowed_methods)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn hash_template_uses_content_hash() {
201 let template =
202 AssetFileNameTemplate::try_new("[name]-[hash].[ext]".to_string(), "asset").unwrap();
203 let first = template.resolve("my-card", "webui.js", b"one");
204 let second = template.resolve("my-card", "webui.js", b"two");
205
206 assert!(first.starts_with("my-card-"));
207 assert!(first.ends_with(".webui.js"));
208 assert_ne!(first, second);
209 }
210
211 #[test]
212 fn rejects_unsafe_characters() {
213 for template in [
214 "[name]:[hash].[ext]",
215 "[name]*[hash].[ext]",
216 "[name]#[hash].[ext]",
217 "[name]%[hash].[ext]",
218 "[name]?[hash].[ext]",
219 "[name]|[hash].[ext]",
220 "resume [hash].[ext]",
221 "résumé-[hash].[ext]",
222 ] {
223 let result = AssetFileNameTemplate::try_new(template.to_string(), "asset");
224 assert!(result.is_err(), "template should be rejected: {template}");
225 }
226 }
227}