Skip to main content

webui_parser/
css_link.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Link-mode CSS filename and public href resolution.
5
6use crate::{ParserError, Result};
7use sha2::{Digest, Sha256};
8use std::collections::HashMap;
9use std::fmt::Write as _;
10use std::sync::{Arc, Mutex};
11
12/// Default Link-mode CSS filename template.
13pub const DEFAULT_CSS_FILE_NAME_TEMPLATE: &str = "[name].[ext]";
14
15/// Configures Link-mode CSS filenames and public hrefs.
16#[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/// Resolved Link-mode CSS asset path data.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct CssLinkHref {
27    /// Local filename emitted by build output.
28    pub filename: String,
29    /// Public href used in parser/handler `<link>` tags.
30    pub href: String,
31}
32
33impl CssLinkOptions {
34    /// Create validated Link-mode CSS options.
35    ///
36    /// # Errors
37    ///
38    /// Returns [`ParserError::Css`] when the template contains unknown tokens,
39    /// unsafe path bytes, or an unsafe public base.
40    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    /// Resolve the local filename and public href for a component stylesheet.
54    ///
55    /// Results are cached by component tag. A `CssLinkOptions` value should be
56    /// scoped to one build/component registry where each tag maps to exactly
57    /// one stylesheet.
58    #[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    /// File name template used for Link-mode CSS files.
94    #[must_use]
95    pub fn file_name_template(&self) -> &str {
96        &self.file_name_template
97    }
98
99    /// Optional public base prepended to Link-mode CSS hrefs.
100    #[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}