1#[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#[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#[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}