1use crate::{ParserError, Result};
7use sha2::{Digest, Sha256};
8use std::collections::HashMap;
9use std::fmt::Write as _;
10use std::sync::{Arc, Mutex};
11
12pub const DEFAULT_CSS_FILE_NAME_TEMPLATE: &str = "[name].[ext]";
14
15#[derive(Debug, Clone)]
17pub struct CssLinkOptions {
18 file_name_template: String,
19 public_base: Option<String>,
20 uses_hash: bool,
21 cache: Arc<Mutex<HashMap<String, CssLinkHref>>>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct CssLinkHref {
27 pub filename: String,
29 pub href: String,
31}
32
33impl CssLinkOptions {
34 pub fn try_new(file_name_template: String, public_base: Option<String>) -> Result<Self> {
41 validate_css_link_template(&file_name_template)?;
42 if let Some(base) = public_base.as_deref() {
43 validate_css_public_base(base)?;
44 }
45 Ok(Self {
46 uses_hash: file_name_template.contains("[hash]"),
47 file_name_template,
48 public_base,
49 cache: Arc::new(Mutex::new(HashMap::new())),
50 })
51 }
52
53 #[must_use]
59 pub fn resolve(&self, tag_name: &str, css_content: &str) -> CssLinkHref {
60 {
61 let cache = self
62 .cache
63 .lock()
64 .unwrap_or_else(std::sync::PoisonError::into_inner);
65 if let Some(cached) = cache.get(tag_name) {
66 return cached.clone();
67 }
68 }
69
70 let safe_tag = tag_name.replace(['/', '\\'], "-");
71 let hash = if self.uses_hash {
72 short_sha256_hex(css_content.as_bytes())
73 } else {
74 String::new()
75 };
76 let filename = format_css_link_filename(&self.file_name_template, &safe_tag, &hash);
77 let href = match self.public_base.as_deref() {
78 Some(base) => join_css_public_base(base, &filename),
79 None => filename.clone(),
80 };
81 let resolved = CssLinkHref { filename, href };
82
83 let mut cache = self
84 .cache
85 .lock()
86 .unwrap_or_else(std::sync::PoisonError::into_inner);
87 cache
88 .entry(tag_name.to_string())
89 .or_insert_with(|| resolved.clone())
90 .clone()
91 }
92
93 #[must_use]
95 pub fn file_name_template(&self) -> &str {
96 &self.file_name_template
97 }
98
99 #[must_use]
101 pub fn public_base(&self) -> Option<&str> {
102 self.public_base.as_deref()
103 }
104}
105
106impl Default for CssLinkOptions {
107 fn default() -> Self {
108 Self {
109 file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
110 public_base: None,
111 uses_hash: false,
112 cache: Arc::new(Mutex::new(HashMap::new())),
113 }
114 }
115}
116
117impl PartialEq for CssLinkOptions {
118 fn eq(&self, other: &Self) -> bool {
119 self.file_name_template == other.file_name_template
120 && self.public_base == other.public_base
121 && self.uses_hash == other.uses_hash
122 }
123}
124
125impl Eq for CssLinkOptions {}
126
127fn validate_css_link_template(template: &str) -> Result<()> {
128 if template.is_empty() {
129 return Err(ParserError::Css(
130 "css_file_name_template cannot be empty".to_string(),
131 ));
132 }
133 if contains_invalid_filename_template_byte(template) {
134 return Err(ParserError::Css(
135 "css_file_name_template must be ASCII and cannot contain path separators, Windows-reserved filename characters, control characters, or whitespace".to_string(),
136 ));
137 }
138 if template.contains("..") {
139 return Err(ParserError::Css(
140 "css_file_name_template cannot contain '..'".to_string(),
141 ));
142 }
143 let bytes = template.as_bytes();
144 let mut i = 0usize;
145 while i < bytes.len() {
146 if bytes[i] == b'[' {
147 let start = i + 1;
148 let mut j = start;
149 while j < bytes.len() && bytes[j] != b']' {
150 j += 1;
151 }
152 if j >= bytes.len() {
153 return Err(ParserError::Css(format!(
154 "Invalid css_file_name_template '{template}': missing closing ']'"
155 )));
156 }
157 let token = &template[start..j];
158 match token {
159 "name" | "hash" | "ext" => {}
160 _ => {
161 return Err(ParserError::Css(format!(
162 "Invalid css_file_name_template '{template}': unknown token '[{token}]'. Allowed tokens: [name], [hash], [ext]"
163 )));
164 }
165 }
166 i = j + 1;
167 continue;
168 }
169 i += 1;
170 }
171
172 Ok(())
173}
174
175fn validate_css_public_base(base: &str) -> Result<()> {
176 if base.is_empty() {
177 return Err(ParserError::Css(
178 "css_public_base cannot be empty".to_string(),
179 ));
180 }
181 if contains_invalid_href_byte(base) {
182 return Err(ParserError::Css(
183 "css_public_base cannot contain quotes, angle brackets, backslashes, or whitespace"
184 .to_string(),
185 ));
186 }
187 Ok(())
188}
189
190fn format_css_link_filename(template: &str, name: &str, hash: &str) -> String {
191 debug_assert!(template.is_ascii());
192 let mut out = String::with_capacity(template.len() + 16);
193 let bytes = template.as_bytes();
194 let mut i = 0usize;
195 while i < bytes.len() {
196 if bytes[i] == b'[' {
197 let start = i + 1;
198 let mut j = start;
199 while j < bytes.len() && bytes[j] != b']' {
200 j += 1;
201 }
202 let token = &template[start..j];
203 match token {
204 "name" => out.push_str(name),
205 "hash" => out.push_str(hash),
206 "ext" => out.push_str("css"),
207 _ => {}
208 }
209 i = j + 1;
210 continue;
211 }
212
213 out.push(char::from(bytes[i]));
214 i += 1;
215 }
216 out
217}
218
219fn short_sha256_hex(content: &[u8]) -> String {
220 let digest = Sha256::digest(content);
221 let mut out = String::with_capacity(8);
222 for b in &digest[..4] {
223 let _ = write!(&mut out, "{b:02x}");
224 }
225 out
226}
227
228fn join_css_public_base(base: &str, css_filename: &str) -> String {
229 let mut href = String::with_capacity(base.len() + css_filename.len() + 1);
230 href.push_str(base);
231 if !base.ends_with('/') {
232 href.push('/');
233 }
234 href.push_str(css_filename);
235 href
236}
237
238fn contains_invalid_href_byte(value: &str) -> bool {
239 value
240 .bytes()
241 .any(|b| matches!(b, b'"' | b'\'' | b'<' | b'>' | b'\\') || b.is_ascii_whitespace())
242}
243
244fn contains_invalid_filename_template_byte(value: &str) -> bool {
245 !value.is_ascii()
246 || value.bytes().any(|b| {
247 matches!(
248 b,
249 0x00..=0x1F | b'"' | b'*' | b'/' | b':' | b'<' | b'>' | b'?' | b'\\' | b'|'
250 ) || b.is_ascii_whitespace()
251 })
252}
253
254#[cfg(test)]
255#[allow(clippy::disallowed_methods)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn default_template_does_not_require_hash() {
261 let options = CssLinkOptions::default();
262 assert!(!options.uses_hash);
263 let resolved = options.resolve("my-card", ".card { color: red; }");
264 assert_eq!(resolved.filename, "my-card.css");
265 }
266
267 #[test]
268 fn hash_template_uses_content_hash() {
269 let options = CssLinkOptions::try_new("[name]-[hash].[ext]".to_string(), None).unwrap();
270 assert!(options.uses_hash);
271 let first = options.resolve("my-card", ".card { color: red; }");
272 let second = options.resolve("other-card", ".card { color: blue; }");
273
274 assert!(first.filename.starts_with("my-card-"));
275 assert!(second.filename.starts_with("other-card-"));
276 assert_ne!(
277 first.filename.rsplit_once('-').map(|(_, hash)| hash),
278 second.filename.rsplit_once('-').map(|(_, hash)| hash)
279 );
280 }
281
282 #[test]
283 fn filename_template_rejects_filesystem_unsafe_characters() {
284 for template in [
285 "[name]:[hash].[ext]",
286 "[name]*[hash].[ext]",
287 "[name]?[hash].[ext]",
288 "[name]|[hash].[ext]",
289 "résumé-[hash].[ext]",
290 ] {
291 let result = CssLinkOptions::try_new(template.to_string(), None);
292 assert!(result.is_err(), "template should be rejected: {template}");
293 }
294 }
295}