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::{AssetFileNameTemplate, ParserError, Result, DEFAULT_ASSET_FILE_NAME_TEMPLATE};
7use std::collections::HashMap;
8use std::sync::{Arc, Mutex};
9
10/// Default Link-mode CSS filename template.
11pub const DEFAULT_CSS_FILE_NAME_TEMPLATE: &str = DEFAULT_ASSET_FILE_NAME_TEMPLATE;
12
13/// Configures Link-mode CSS filenames and public hrefs.
14#[derive(Debug, Clone)]
15pub struct CssLinkOptions {
16    file_name_template: AssetFileNameTemplate,
17    public_base: Option<String>,
18    cache: Arc<Mutex<HashMap<String, CssLinkHref>>>,
19}
20
21/// Resolved Link-mode CSS asset path data.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct CssLinkHref {
24    /// Local filename emitted by build output.
25    pub filename: String,
26    /// Public href used in parser/handler `<link>` tags.
27    pub href: String,
28}
29
30impl CssLinkOptions {
31    /// Create validated Link-mode CSS options.
32    ///
33    /// # Errors
34    ///
35    /// Returns [`ParserError::Css`] when the template contains unknown tokens,
36    /// unsafe path bytes, or an unsafe public base.
37    pub fn try_new(file_name_template: String, public_base: Option<String>) -> Result<Self> {
38        let file_name_template =
39            AssetFileNameTemplate::try_new(file_name_template, "css_file_name_template")
40                .map_err(|error| ParserError::Css(error.to_string()))?;
41        if let Some(base) = public_base.as_deref() {
42            validate_css_public_base(base)?;
43        }
44        Ok(Self {
45            file_name_template,
46            public_base,
47            cache: Arc::new(Mutex::new(HashMap::new())),
48        })
49    }
50
51    /// Resolve the local filename and public href for a component stylesheet.
52    ///
53    /// Results are cached by component tag. A `CssLinkOptions` value should be
54    /// scoped to one build/component registry where each tag maps to exactly
55    /// one stylesheet.
56    #[must_use]
57    pub fn resolve(&self, tag_name: &str, css_content: &str) -> CssLinkHref {
58        {
59            let cache = self
60                .cache
61                .lock()
62                .unwrap_or_else(std::sync::PoisonError::into_inner);
63            if let Some(cached) = cache.get(tag_name) {
64                return cached.clone();
65            }
66        }
67
68        let filename = self
69            .file_name_template
70            .resolve(tag_name, "css", css_content.as_bytes());
71        let href = match self.public_base.as_deref() {
72            Some(base) => join_css_public_base(base, &filename),
73            None => filename.clone(),
74        };
75        let resolved = CssLinkHref { filename, href };
76
77        let mut cache = self
78            .cache
79            .lock()
80            .unwrap_or_else(std::sync::PoisonError::into_inner);
81        cache
82            .entry(tag_name.to_string())
83            .or_insert_with(|| resolved.clone())
84            .clone()
85    }
86
87    /// Resolve the local filename and public href for a bundled CSS chunk.
88    ///
89    /// Chunk names live in a build-owned namespace rather than the component
90    /// registry, so this deliberately bypasses the per-tag cache: a chunk and a
91    /// component that happen to share a name must never resolve to each other's
92    /// content hash.
93    #[must_use]
94    pub fn resolve_chunk(&self, chunk_name: &str, css_content: &str) -> CssLinkHref {
95        let filename = self
96            .file_name_template
97            .resolve(chunk_name, "css", css_content.as_bytes());
98        let href = match self.public_base.as_deref() {
99            Some(base) => join_css_public_base(base, &filename),
100            None => filename.clone(),
101        };
102        CssLinkHref { filename, href }
103    }
104
105    /// File name template used for Link-mode CSS files.
106    #[must_use]
107    pub fn file_name_template(&self) -> &str {
108        self.file_name_template.file_name_template()
109    }
110
111    /// Optional public base prepended to Link-mode CSS hrefs.
112    #[must_use]
113    pub fn public_base(&self) -> Option<&str> {
114        self.public_base.as_deref()
115    }
116}
117
118impl Default for CssLinkOptions {
119    fn default() -> Self {
120        Self {
121            file_name_template: AssetFileNameTemplate::new_unchecked(
122                DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
123            ),
124            public_base: None,
125            cache: Arc::new(Mutex::new(HashMap::new())),
126        }
127    }
128}
129
130impl PartialEq for CssLinkOptions {
131    fn eq(&self, other: &Self) -> bool {
132        self.file_name_template == other.file_name_template && self.public_base == other.public_base
133    }
134}
135
136impl Eq for CssLinkOptions {}
137
138fn validate_css_public_base(base: &str) -> Result<()> {
139    if base.is_empty() {
140        return Err(ParserError::Css(
141            "css_public_base cannot be empty".to_string(),
142        ));
143    }
144    if contains_invalid_href_byte(base) {
145        return Err(ParserError::Css(
146            "css_public_base cannot contain quotes, angle brackets, backslashes, or whitespace"
147                .to_string(),
148        ));
149    }
150    Ok(())
151}
152
153fn join_css_public_base(base: &str, css_filename: &str) -> String {
154    let mut href = String::with_capacity(base.len() + css_filename.len() + 1);
155    href.push_str(base);
156    if !base.ends_with('/') {
157        href.push('/');
158    }
159    href.push_str(css_filename);
160    href
161}
162
163fn contains_invalid_href_byte(value: &str) -> bool {
164    value
165        .bytes()
166        .any(|b| matches!(b, b'"' | b'\'' | b'<' | b'>' | b'\\') || b.is_ascii_whitespace())
167}
168
169#[cfg(test)]
170#[allow(clippy::disallowed_methods)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn default_template_does_not_require_hash() {
176        let options = CssLinkOptions::default();
177        assert!(!options.file_name_template.uses_hash());
178        let resolved = options.resolve("my-card", ".card { color: red; }");
179        assert_eq!(resolved.filename, "my-card.css");
180    }
181
182    #[test]
183    fn hash_template_uses_content_hash() {
184        let options = CssLinkOptions::try_new("[name]-[hash].[ext]".to_string(), None).unwrap();
185        assert!(options.file_name_template.uses_hash());
186        let first = options.resolve("my-card", ".card { color: red; }");
187        let second = options.resolve("other-card", ".card { color: blue; }");
188
189        assert!(first.filename.starts_with("my-card-"));
190        assert!(second.filename.starts_with("other-card-"));
191        assert_ne!(
192            first.filename.rsplit_once('-').map(|(_, hash)| hash),
193            second.filename.rsplit_once('-').map(|(_, hash)| hash)
194        );
195    }
196
197    #[test]
198    fn filename_template_rejects_filesystem_unsafe_characters() {
199        for template in [
200            "[name]:[hash].[ext]",
201            "[name]*[hash].[ext]",
202            "[name]?[hash].[ext]",
203            "[name]|[hash].[ext]",
204            "résumé-[hash].[ext]",
205        ] {
206            let result = CssLinkOptions::try_new(template.to_string(), None);
207            assert!(result.is_err(), "template should be rejected: {template}");
208        }
209    }
210}