Skip to main content

webui_parser/
asset_filename.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Shared filename-template handling for emitted build assets.
5
6use sha2::{Digest, Sha256};
7use std::fmt::Write as _;
8use thiserror::Error;
9
10/// Default build-asset filename template.
11pub const DEFAULT_ASSET_FILE_NAME_TEMPLATE: &str = "[name].[ext]";
12
13/// Error returned when an asset filename template is invalid.
14#[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/// Validated filename template for emitted build assets.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct AssetFileNameTemplate {
29    file_name_template: String,
30    uses_hash: bool,
31}
32
33impl AssetFileNameTemplate {
34    /// Create a validated asset filename template.
35    ///
36    /// Supported tokens are `[name]`, `[hash]`, and `[ext]`.
37    ///
38    /// # Errors
39    ///
40    /// Returns [`AssetFileNameTemplateError`] when the template is empty,
41    /// contains unsafe filename characters, or uses an unknown token.
42    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    /// Resolve a concrete filename for an emitted asset.
61    ///
62    /// `[hash]` is the SHA-256 content hash truncated to 8 hex characters.
63    #[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    /// Filename template text.
75    #[must_use]
76    pub fn file_name_template(&self) -> &str {
77        &self.file_name_template
78    }
79
80    /// Whether the template includes `[hash]`.
81    #[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}