Skip to main content

webui/
component_asset_output.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Transactional publication of generated component ESM inputs.
5
6#[path = "component_asset_output/transaction.rs"]
7mod transaction;
8
9use crate::{ComponentAssetFile, WebUIError};
10use std::collections::HashSet;
11use std::fs;
12use std::path::Path;
13use std::sync::atomic::{AtomicU64, Ordering};
14use transaction::Publication;
15
16type Result<T> = std::result::Result<T, WebUIError>;
17
18trait Context<T> {
19    fn context(self, context: impl Into<String>) -> Result<T>;
20    fn with_context(self, context: impl FnOnce() -> String) -> Result<T>;
21}
22
23impl<T, E: std::error::Error + Send + Sync + 'static> Context<T> for std::result::Result<T, E> {
24    fn context(self, context: impl Into<String>) -> Result<T> {
25        self.map_err(|source| publication_error(context.into(), source))
26    }
27
28    fn with_context(self, context: impl FnOnce() -> String) -> Result<T> {
29        self.map_err(|source| publication_error(context(), source))
30    }
31}
32
33#[cold]
34#[inline(never)]
35fn publication_error(
36    context: String,
37    source: impl std::error::Error + Send + Sync + 'static,
38) -> WebUIError {
39    WebUIError::ComponentAssetPublication {
40        context,
41        source: Box::new(source),
42    }
43}
44
45static PUBLISH_ID: AtomicU64 = AtomicU64::new(0);
46
47/// Publish generated component inputs without deleting unrelated or older files.
48///
49/// Content-addressed dependencies are written before stable root entries.
50/// Unchanged files keep their identity and modification times. The output
51/// directory is developer-owned; callers may remove it when they need a clean
52/// build. Hosts must serialize publishers targeting the same directory.
53///
54/// # Errors
55///
56/// Returns an error on invalid filenames, content-address collisions, or I/O
57/// failure. Failed publication restores stable roots changed by this call.
58/// Immutable payloads already published remain available to concurrent readers.
59#[must_use = "component asset publication failures must be handled"]
60pub fn publish(output_dir: &Path, files: &[ComponentAssetFile]) -> Result<()> {
61    publish_with(output_dir, files, || Ok(()))
62}
63
64/// Publish assets and finalize a related output within the rollback boundary.
65///
66/// `finish` must itself preserve its previous output on failure. It is invoked
67/// even for an unchanged graph, without rewriting component inputs.
68///
69/// # Errors
70///
71/// Returns publication or finalization errors after rolling back stable roots
72/// changed by this call. Immutable payloads already published remain available
73/// to concurrent readers.
74#[must_use = "component asset finalization failures must be handled"]
75pub fn publish_with<F>(output_dir: &Path, files: &[ComponentAssetFile], finish: F) -> Result<()>
76where
77    F: FnOnce() -> Result<()>,
78{
79    validate_files(files)?;
80    validate_payload_directory(output_dir)?;
81    fs::create_dir_all(output_dir)
82        .with_context(|| format!("Failed to create {}", output_dir.display()))?;
83    let mut changed = Vec::with_capacity(files.len());
84    for file in files {
85        if !unchanged_file(output_dir, file)? {
86            changed.push(file);
87        }
88    }
89    if changed.is_empty() {
90        return finish();
91    }
92
93    let publish_id = PUBLISH_ID.fetch_add(1, Ordering::Relaxed);
94    let temporary_dir = output_dir.join(format!(
95        ".webui-component-assets-tmp-{}-{publish_id}",
96        std::process::id()
97    ));
98    fs::create_dir(&temporary_dir)
99        .with_context(|| format!("Failed to create {}", temporary_dir.display()))?;
100
101    let mut preserve_staging = false;
102    let result = (|| {
103        for file in &changed {
104            write_staged_file(&temporary_dir, file)?;
105        }
106
107        let mut publication = Publication::new(output_dir, &temporary_dir);
108        let published = (|| {
109            publication.publish_group(&changed, false)?;
110            publication.publish_group(&changed, true)?;
111            finish()
112        })();
113        match published {
114            Ok(()) => Ok(()),
115            Err(error) => match publication.rollback() {
116                Ok(()) => Err(error),
117                Err(rollback) => {
118                    preserve_staging = true;
119                    Err(WebUIError::ComponentAssetPublication {
120                        context: format!(
121                            "Rollback also failed: {}. Recovery files remain in {}",
122                            rollback.chain_message(),
123                            temporary_dir.display()
124                        ),
125                        source: Box::new(error),
126                    })
127                }
128            },
129        }
130    })();
131    if !preserve_staging {
132        if let Err(error) = fs::remove_dir_all(&temporary_dir) {
133            log::warn!(
134                "Failed to clean component asset staging directory {}: {error}",
135                temporary_dir.display()
136            );
137        }
138    }
139    result
140}
141
142fn is_root_file(file: &ComponentAssetFile) -> bool {
143    !file.name.starts_with("components/")
144}
145
146fn is_owned_file_name(name: &str) -> bool {
147    if name.contains('\\') {
148        return false;
149    }
150    let mut parts = name.split('/');
151    match (parts.next(), parts.next(), parts.next()) {
152        (Some(file), None, None) => is_component_asset_file_name(file),
153        (Some("components"), Some(file), None) => is_component_payload_file_name(file),
154        _ => false,
155    }
156}
157
158fn validate_files(files: &[ComponentAssetFile]) -> Result<()> {
159    let mut seen = HashSet::with_capacity(files.len());
160    for file in files {
161        if !is_owned_file_name(&file.name) || !seen.insert(file.name.as_str()) {
162            return Err(WebUIError::InvalidBuildOptions(format!(
163                "invalid or duplicate generated component asset path '{}'",
164                file.name
165            )));
166        }
167    }
168    Ok(())
169}
170
171fn unchanged_file(output_dir: &Path, file: &ComponentAssetFile) -> Result<bool> {
172    let path = output_dir.join(&file.name);
173    match fs::symlink_metadata(&path) {
174        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
175            return Err(WebUIError::InvalidBuildOptions(format!(
176                "Component asset output {} must be a regular file, not a directory or symlink",
177                path.display()
178            )));
179        }
180        Ok(_) => {}
181        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
182        Err(error) => {
183            return Err(error)
184                .with_context(|| format!("Failed to inspect component asset {}", path.display()));
185        }
186    }
187    match fs::read(&path) {
188        Ok(content) if content == file.content.as_bytes() => Ok(true),
189        Ok(_) if file.name.starts_with("components/") => Err(WebUIError::InvalidBuildOptions(
190            format!("Component asset content hash collision for {}", file.name),
191        )),
192        Ok(_) => Ok(false),
193        Err(error) => {
194            Err(error).with_context(|| format!("Failed to read component asset {}", path.display()))
195        }
196    }
197}
198
199fn validate_payload_directory(output_dir: &Path) -> Result<()> {
200    let path = output_dir.join("components");
201    match fs::symlink_metadata(&path) {
202        Ok(metadata) if metadata.is_dir() => Ok(()),
203        Ok(_) => Err(WebUIError::InvalidBuildOptions(format!(
204            "{} must be a real component payload directory. Remove the conflicting file or symlink, or choose another output directory.",
205            path.display()
206        ))),
207        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
208        Err(error) => Err(error).context("Failed to inspect component payload directory"),
209    }
210}
211
212fn is_component_payload_file_name(name: &str) -> bool {
213    const CONTENT_ID_LEN: usize = 16;
214    let Some(stem) = name.strip_suffix(".webui.js") else {
215        return false;
216    };
217    let Some((tag, content_id)) = stem.rsplit_once('.') else {
218        return false;
219    };
220    content_id.len() == CONTENT_ID_LEN
221        && content_id
222            .bytes()
223            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
224        && is_component_tag(tag)
225}
226
227fn is_component_asset_file_name(name: &str) -> bool {
228    let Some(tag) = name.strip_suffix(".webui.js") else {
229        return false;
230    };
231    is_component_tag(tag)
232}
233
234fn is_component_tag(tag: &str) -> bool {
235    let bytes = tag.as_bytes();
236    !bytes.is_empty()
237        && bytes.contains(&b'-')
238        && bytes[0].is_ascii_lowercase()
239        && bytes[bytes.len() - 1].is_ascii_alphanumeric()
240        && bytes
241            .iter()
242            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
243}
244
245fn write_staged_file(temporary_dir: &Path, file: &ComponentAssetFile) -> Result<()> {
246    let path = temporary_dir.join(&file.name);
247    if let Some(parent) = path.parent() {
248        fs::create_dir_all(parent)
249            .with_context(|| format!("Failed to create {}", parent.display()))?;
250    }
251    fs::write(path, &file.content)
252        .with_context(|| format!("Failed to stage component asset {}", file.name))
253}
254
255#[cfg(test)]
256#[allow(clippy::disallowed_methods)]
257mod tests {
258    use super::*;
259    use tempfile::TempDir;
260
261    fn file(name: &str, content: &str) -> ComponentAssetFile {
262        ComponentAssetFile {
263            name: name.to_string(),
264            content: content.to_string(),
265        }
266    }
267
268    #[test]
269    fn publish_overwrites_roots_and_leaves_older_payloads_alone() {
270        const OLD_PAYLOAD: &str = "components/lazy-card.aaaaaaaaaaaaaaaa.webui.js";
271        const NEW_PAYLOAD: &str = "components/lazy-card.bbbbbbbbbbbbbbbb.webui.js";
272        let output = TempDir::new().unwrap();
273        publish(
274            output.path(),
275            &[
276                file(OLD_PAYLOAD, "old component"),
277                file("lazy-card.webui.js", "old root"),
278            ],
279        )
280        .unwrap();
281        fs::write(output.path().join("application.js"), "owned by app").unwrap();
282
283        publish(
284            output.path(),
285            &[
286                file(NEW_PAYLOAD, "new component"),
287                file("lazy-card.webui.js", "new root"),
288            ],
289        )
290        .unwrap();
291
292        assert!(output.path().join(OLD_PAYLOAD).is_file());
293        assert!(output.path().join(NEW_PAYLOAD).is_file());
294        assert_eq!(
295            fs::read_to_string(output.path().join("lazy-card.webui.js")).unwrap(),
296            "new root"
297        );
298        assert_eq!(
299            fs::read_to_string(output.path().join("application.js")).unwrap(),
300            "owned by app"
301        );
302    }
303
304    #[test]
305    fn finish_failure_restores_roots_and_retains_published_payloads() {
306        const PAYLOAD: &str = "components/new-card.bbbbbbbbbbbbbbbb.webui.js";
307        let output = TempDir::new().unwrap();
308        publish(output.path(), &[file("stable-root.webui.js", "old root")]).unwrap();
309
310        let error = publish_with(
311            output.path(),
312            &[
313                file("stable-root.webui.js", "new root"),
314                file(PAYLOAD, "new payload"),
315            ],
316            || {
317                assert_eq!(
318                    fs::read_to_string(output.path().join("stable-root.webui.js")).unwrap(),
319                    "new root"
320                );
321                assert!(output.path().join(PAYLOAD).is_file());
322                Err(WebUIError::InvalidBuildOptions(
323                    "metafile commit failed".to_string(),
324                ))
325            },
326        )
327        .unwrap_err();
328
329        assert!(error.to_string().contains("metafile commit failed"));
330        assert_eq!(
331            fs::read_to_string(output.path().join("stable-root.webui.js")).unwrap(),
332            "old root"
333        );
334        assert_eq!(
335            fs::read_to_string(output.path().join(PAYLOAD)).unwrap(),
336            "new payload"
337        );
338    }
339
340    #[test]
341    fn content_hash_collision_preserves_published_payload() {
342        const PAYLOAD: &str = "components/test-card.0000000100000000.webui.js";
343        let output = TempDir::new().unwrap();
344        publish(output.path(), &[file(PAYLOAD, "a")]).unwrap();
345        let error = publish(output.path(), &[file(PAYLOAD, "b")]).unwrap_err();
346        assert!(error.to_string().contains("content hash collision"));
347        assert_eq!(
348            fs::read_to_string(output.path().join(PAYLOAD)).unwrap(),
349            "a"
350        );
351    }
352
353    #[test]
354    fn unchanged_graph_preserves_file_identities_and_runs_finish() {
355        const PAYLOAD: &str = "components/test-card.0000000100000000.webui.js";
356        let output = TempDir::new().unwrap();
357        let files = [file("stable-root.webui.js", "root"), file(PAYLOAD, "a")];
358        publish(output.path(), &files).unwrap();
359        let paths = [
360            output.path().join("stable-root.webui.js"),
361            output.path().join(PAYLOAD),
362        ];
363        let before = paths
364            .each_ref()
365            .map(|path| fs::metadata(path).unwrap().modified().unwrap());
366        #[cfg(unix)]
367        let inodes = paths.each_ref().map(|path| {
368            use std::os::unix::fs::MetadataExt;
369            fs::metadata(path).unwrap().ino()
370        });
371        let mut finished = false;
372        publish_with(output.path(), &files, || {
373            finished = true;
374            Ok(())
375        })
376        .unwrap();
377        assert!(finished);
378        let after = paths
379            .each_ref()
380            .map(|path| fs::metadata(path).unwrap().modified().unwrap());
381        assert_eq!(before, after);
382        #[cfg(unix)]
383        assert_eq!(
384            inodes,
385            paths.each_ref().map(|path| {
386                use std::os::unix::fs::MetadataExt;
387                fs::metadata(path).unwrap().ino()
388            })
389        );
390    }
391
392    #[test]
393    fn invalid_paths_fail_before_writing_any_outputs() {
394        let output = TempDir::new().unwrap();
395        for name in [
396            "../escape.webui.js",
397            "not-a-component.js",
398            "components/invalid.webui.js",
399        ] {
400            assert!(publish(output.path(), &[file(name, "invalid")]).is_err());
401        }
402        assert_eq!(fs::read_dir(output.path()).unwrap().count(), 0);
403    }
404
405    #[cfg(unix)]
406    #[test]
407    fn publication_rejects_symlinked_payload_directories() {
408        let output = TempDir::new().unwrap();
409        let external = TempDir::new().unwrap();
410        std::os::unix::fs::symlink(external.path(), output.path().join("components")).unwrap();
411        assert!(publish(output.path(), &[]).is_err());
412        assert_eq!(fs::read_dir(external.path()).unwrap().count(), 0);
413    }
414
415    #[cfg(unix)]
416    #[test]
417    fn publication_rejects_regular_and_dangling_root_symlinks() {
418        let output = TempDir::new().unwrap();
419        let target = output.path().join("target.js");
420        fs::write(&target, "target").unwrap();
421        let root = output.path().join("stable-root.webui.js");
422        std::os::unix::fs::symlink(&target, &root).unwrap();
423
424        assert!(publish(output.path(), &[file("stable-root.webui.js", "new")]).is_err());
425        assert!(fs::symlink_metadata(&root)
426            .unwrap()
427            .file_type()
428            .is_symlink());
429        assert_eq!(fs::read_to_string(&target).unwrap(), "target");
430
431        fs::remove_file(&root).unwrap();
432        std::os::unix::fs::symlink(output.path().join("missing.js"), &root).unwrap();
433        assert!(publish(output.path(), &[file("stable-root.webui.js", "new")]).is_err());
434        assert!(fs::symlink_metadata(&root)
435            .unwrap()
436            .file_type()
437            .is_symlink());
438    }
439}