1use crate::error::{PathErrorExt, SsgError};
21use crate::plugin::{Plugin, PluginContext};
22use crate::util::html_rewriter::inject_before_body_close_or_append;
23use std::{collections::BTreeSet, fs, path::Path};
24
25#[derive(Debug, Clone, Copy)]
27pub struct IslandPlugin;
28
29impl Default for IslandPlugin {
30 fn default() -> Self {
31 Self::new()
32 }
33}
34
35impl IslandPlugin {
36 #[must_use]
48 pub const fn new() -> Self {
49 Self
50 }
51}
52
53impl Plugin for IslandPlugin {
54 fn name(&self) -> &'static str {
55 "islands"
56 }
57
58 fn has_transform(&self) -> bool {
59 true
60 }
61
62 fn transform_html(
63 &self,
64 html: &str,
65 _path: &Path,
66 ctx: &PluginContext,
67 ) -> Result<String, SsgError> {
68 if !html.contains("<ssg-island") {
69 return Ok(html.to_string());
70 }
71
72 if html.contains("ssg-island.js") {
73 return Ok(html.to_string()); }
75
76 let prefix = ctx.config.as_ref().map_or_else(String::new, |c| {
80 crate::plugins_group::csp::base_url_path_prefix(&c.base_url)
81 });
82 let script = format!(
83 "\n<script type=\"module\" src=\"{prefix}/_islands/ssg-island.js\"></script>\n"
84 );
85
86 let output = inject_before_body_close_or_append(html, &script);
87
88 Ok(output)
89 }
90
91 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
92 if !ctx.site_dir.exists() {
93 return Ok(());
94 }
95
96 let html_files = ctx.get_html_files();
97
98 let mut components = BTreeSet::new();
100
101 for path in &html_files {
102 let html = fs::read_to_string(path).with_path(path)?;
103 let page_components = extract_island_components(&html);
104 components.extend(page_components);
105 }
106
107 if components.is_empty() {
108 return Ok(());
109 }
110
111 let islands_dir = ctx.site_dir.join("_islands");
112 fs::create_dir_all(&islands_dir).with_path(&islands_dir)?;
113
114 let source_islands = ctx
116 .content_dir
117 .parent()
118 .unwrap_or(&ctx.content_dir)
119 .join("islands");
120
121 if source_islands.exists() {
122 for component in &components {
123 let src = source_islands.join(format!("{component}.js"));
124 if src.exists() {
125 let dst = islands_dir.join(format!("{component}.js"));
126 let _ = fs::copy(&src, &dst).with_path(&dst)?;
127 }
128 }
129 }
130
131 let manifest: Vec<_> = components.iter().collect();
133 let manifest_json = serde_json::to_string_pretty(&manifest)
134 .unwrap_or_else(|_| "[]".to_string());
135 let manifest_path = islands_dir.join("manifest.json");
136 fs::write(&manifest_path, manifest_json).with_path(&manifest_path)?;
137
138 let loader_path = islands_dir.join("ssg-island.js");
140 fs::write(&loader_path, ISLAND_LOADER_JS).with_path(&loader_path)?;
141
142 log::info!("[islands] {} component(s) bundled", components.len());
143 Ok(())
144 }
145}
146
147fn extract_island_components(html: &str) -> BTreeSet<String> {
149 let mut components = BTreeSet::new();
150
151 let mut search_from = 0;
152 while let Some(tag_start) = html[search_from..].find("<ssg-island") {
153 let abs_start = search_from + tag_start;
154 let rest = &html[abs_start..];
155
156 if let Some(tag_end) = rest.find('>') {
157 let tag = &rest[..tag_end];
158 if let Some(component) = attr_value(tag, "component") {
159 if !component.is_empty() {
160 let _ = components.insert(component);
161 }
162 }
163 search_from = abs_start + tag_end;
164 } else {
165 break;
166 }
167 }
168
169 components
170}
171
172fn attr_value(tag: &str, name: &str) -> Option<String> {
184 let mut from = 0;
185 loop {
186 let at = tag[from..].find(name)? + from;
187 let before_ok = at == 0
188 || tag[..at]
189 .chars()
190 .next_back()
191 .is_some_and(|c| c.is_whitespace());
192 let after = &tag[at + name.len()..];
193 if before_ok && after.starts_with('=') {
196 let value = &after[1..];
197 return Some(match value.chars().next()? {
198 q @ ('"' | '\'') => {
199 value[1..].find(q).map(|e| value[1..1 + e].to_string())?
200 }
201 _ => value
202 .find(|c: char| c.is_whitespace() || c == '>')
203 .map_or_else(
204 || value.to_string(),
205 |e| value[..e].to_string(),
206 ),
207 });
208 }
209 from = at + name.len();
210 }
211}
212
213#[cfg(test)]
214fn inject_island_loader(path: &Path) -> Result<(), SsgError> {
215 let html = fs::read_to_string(path).with_path(path)?;
216
217 if html.contains("ssg-island.js") {
218 return Ok(()); }
220
221 let script =
222 "\n<script type=\"module\" src=\"/_islands/ssg-island.js\"></script>\n";
223
224 let output = inject_before_body_close_or_append(&html, script);
225
226 fs::write(path, output).with_path(path)?;
227 Ok(())
228}
229
230const ISLAND_LOADER_JS: &str = r#"/**
251 * SSG Island — lazy-hydrating Web Component loader.
252 * Each <ssg-island> loads its component bundle on demand.
253 *
254 * Hydration strategies: visible | idle | interaction (default visible)
255 * Lifecycle:
256 * connectedCallback → arm strategy
257 * detach() / disconnectedCallback / ssg:detach → tear down
258 */
259class SsgIsland extends HTMLElement {
260 constructor() {
261 super();
262 this._cleanup = [];
263 this._hydrated = false;
264 this.addEventListener('ssg:detach', () => this.detach());
265 }
266
267 connectedCallback() {
268 const strategy = this.getAttribute('hydrate') || 'visible';
269 const component = this.getAttribute('component');
270 if (!component) return;
271
272 const load = () => this._hydrate(component);
273
274 if (strategy === 'idle') {
275 let handle;
276 if ('requestIdleCallback' in window) {
277 handle = requestIdleCallback(load);
278 this._cleanup.push(() => {
279 if ('cancelIdleCallback' in window) cancelIdleCallback(handle);
280 });
281 } else {
282 handle = setTimeout(load, 200);
283 this._cleanup.push(() => clearTimeout(handle));
284 }
285 } else if (strategy === 'interaction') {
286 const events = ['click', 'focusin', 'pointerover'];
287 const once = () => {
288 events.forEach(e => this.removeEventListener(e, once));
289 load();
290 };
291 events.forEach(e => this.addEventListener(e, once, { once: true }));
292 this._cleanup.push(() => {
293 events.forEach(e => this.removeEventListener(e, once));
294 });
295 } else {
296 // Default: visible (IntersectionObserver, AC4)
297 const io = new IntersectionObserver((entries, obs) => {
298 if (entries[0] && entries[0].isIntersecting) {
299 obs.disconnect();
300 load();
301 }
302 });
303 io.observe(this);
304 this._cleanup.push(() => io.disconnect());
305 }
306 }
307
308 disconnectedCallback() {
309 this.detach();
310 }
311
312 /**
313 * Tear down any pending hydration triggers and notify the loaded
314 * component (if it exposed `detach`). Idempotent and safe to call
315 * multiple times — used by the view-transitions client (#547) to
316 * clean up before swapping <main>.
317 */
318 detach() {
319 while (this._cleanup.length) {
320 const fn = this._cleanup.pop();
321 try { fn(); } catch (e) {}
322 }
323 if (this._module && typeof this._module.detach === 'function') {
324 try { this._module.detach(this); } catch (e) {}
325 }
326 this._module = null;
327 }
328
329 async _hydrate(component) {
330 if (this._hydrated) return;
331 this._hydrated = true;
332 try {
333 const props = JSON.parse(this.getAttribute('props') || '{}');
334 const mod = await import(new URL(`./${component}.js`, import.meta.url).href);
335 this._module = mod;
336 if (mod.default) mod.default(this, props);
337 else if (mod.hydrate) mod.hydrate(this, props);
338 } catch (e) {
339 console.error(`[ssg-island] Failed to hydrate "${component}":`, e);
340 }
341 }
342}
343
344customElements.define('ssg-island', SsgIsland);
345
346// Re-arm islands on view-transition page swaps (issue #547).
347// The transitions client dispatches `ssg:after-swap` after each
348// successful navigation; the new <main> is fresh DOM, so the
349// browser's own connectedCallback fires automatically. We only
350// need to re-confirm any island whose connectedCallback may have
351// raced with the swap.
352document.addEventListener('ssg:after-swap', () => {
353 document.querySelectorAll('ssg-island').forEach(el => {
354 if (el.isConnected && !el._hydrated && el._cleanup.length === 0) {
355 try { el.connectedCallback(); } catch (e) {}
356 }
357 });
358});
359"#;
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use tempfile::tempdir;
365
366 #[test]
371 fn island_components_are_found_whatever_the_quoting() {
372 for html in [
373 r#"<ssg-island component="feature-tabs"></ssg-island>"#,
374 r#"<ssg-island component='feature-tabs'></ssg-island>"#,
375 r#"<ssg-island component=feature-tabs></ssg-island>"#,
376 r#"<ssg-island props='{"a":1}' component=feature-tabs hydrate=visible>"#,
377 ] {
378 let got = extract_island_components(html);
379 assert!(
380 got.contains("feature-tabs"),
381 "missed the component in: {html}"
382 );
383 }
384 }
385
386 #[test]
389 fn island_component_lookup_is_not_fooled_by_a_prefix_match() {
390 let html =
391 r#"<ssg-island component-id=nope component=real></ssg-island>"#;
392 let got = extract_island_components(html);
393 assert!(got.contains("real"), "{got:?}");
394 assert!(!got.contains("nope"), "{got:?}");
395 }
396
397 #[test]
399 fn island_components_collect_across_a_page() {
400 let html = concat!(
401 "<ssg-island component=feature-tabs></ssg-island>",
402 r#"<ssg-island component="pricing-toggle"></ssg-island>"#,
403 );
404 let got = extract_island_components(html);
405 assert_eq!(got.len(), 2, "{got:?}");
406 }
407
408 #[test]
409 fn extract_components_finds_all() {
410 let html = r#"
411 <ssg-island component="counter" hydrate="visible"></ssg-island>
412 <p>Some text</p>
413 <ssg-island component="search" hydrate="idle"></ssg-island>
414 "#;
415 let components = extract_island_components(html);
416 assert_eq!(components.len(), 2);
417 assert!(components.contains("counter"));
418 assert!(components.contains("search"));
419 }
420
421 #[test]
422 fn extract_components_deduplicates() {
423 let html = r#"
424 <ssg-island component="counter" hydrate="visible"></ssg-island>
425 <ssg-island component="counter" hydrate="idle"></ssg-island>
426 "#;
427 let components = extract_island_components(html);
428 assert_eq!(components.len(), 1);
429 }
430
431 #[test]
432 fn extract_components_empty_html() {
433 let components =
434 extract_island_components("<html><body></body></html>");
435 assert!(components.is_empty());
436 }
437
438 #[test]
439 fn inject_loader_adds_script() {
440 let dir = tempdir().unwrap();
441 let html_path = dir.path().join("index.html");
442 fs::write(&html_path, "<html><body></body></html>").unwrap();
443
444 inject_island_loader(&html_path).unwrap();
445
446 let output = fs::read_to_string(&html_path).unwrap();
447 assert!(output.contains("ssg-island.js"));
448 }
449
450 #[test]
451 fn inject_loader_idempotent() {
452 let dir = tempdir().unwrap();
453 let html_path = dir.path().join("index.html");
454 fs::write(&html_path, "<html><body><script type=\"module\" src=\"/_islands/ssg-island.js\"></script></body></html>").unwrap();
455
456 inject_island_loader(&html_path).unwrap();
457
458 let output = fs::read_to_string(&html_path).unwrap();
459 assert_eq!(output.matches("ssg-island.js").count(), 1);
461 }
462
463 #[test]
464 fn island_plugin_name() {
465 assert_eq!(IslandPlugin.name(), "islands");
466 }
467
468 #[test]
469 fn island_plugin_skips_missing_site_dir() {
470 let ctx = PluginContext::new(
471 Path::new("/tmp/c"),
472 Path::new("/tmp/b"),
473 Path::new("/nonexistent/site"),
474 Path::new("/tmp/t"),
475 );
476 assert!(IslandPlugin.after_compile(&ctx).is_ok());
477 }
478
479 #[test]
480 fn island_plugin_processes_pages_with_islands() {
481 let dir = tempdir().unwrap();
482 let site = dir.path().join("site");
483 let content = dir.path().join("content");
484 let islands_src = dir.path().join("islands");
485 fs::create_dir_all(&site).unwrap();
486 fs::create_dir_all(&content).unwrap();
487 fs::create_dir_all(&islands_src).unwrap();
488
489 fs::write(
491 islands_src.join("counter.js"),
492 "export default (el, props) => {};",
493 )
494 .unwrap();
495
496 let html_content = "<html><body><ssg-island component=\"counter\" hydrate=\"visible\"></ssg-island></body></html>";
498 fs::write(site.join("index.html"), html_content).unwrap();
499
500 let ctx = PluginContext::new(&content, dir.path(), &site, dir.path());
501 IslandPlugin.after_compile(&ctx).unwrap();
502
503 assert!(site.join("_islands/manifest.json").exists());
505 assert!(site.join("_islands/ssg-island.js").exists());
507 assert!(site.join("_islands/counter.js").exists());
509 let output = IslandPlugin
511 .transform_html(html_content, &site.join("index.html"), &ctx)
512 .unwrap();
513 assert!(output.contains("ssg-island.js"));
514 }
515
516 #[test]
517 fn island_plugin_no_islands_in_html() {
518 let dir = tempdir().unwrap();
519 let site = dir.path().join("site");
520 fs::create_dir_all(&site).unwrap();
521 fs::write(
522 site.join("index.html"),
523 "<html><body><p>No islands here</p></body></html>",
524 )
525 .unwrap();
526
527 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
528 IslandPlugin.after_compile(&ctx).unwrap();
529
530 assert!(!site.join("_islands").exists());
532 }
533
534 #[test]
535 fn island_shortcode_expansion() {
536 let input = r#"{{< island component="counter" hydrate="visible" >}}"#;
537 let result = crate::shortcodes::expand_shortcodes(input);
538 assert!(result.contains("<ssg-island"));
539 assert!(result.contains("component=\"counter\""));
540 assert!(result.contains("hydrate=\"visible\""));
541 }
542
543 #[test]
544 #[allow(clippy::default_constructed_unit_structs)]
547 fn island_plugin_new_and_default_yield_same_unit() {
548 let a = IslandPlugin::new();
552 let b = IslandPlugin::default();
553 assert_eq!(a.name(), b.name());
554 assert!(a.has_transform());
555 assert!(b.has_transform());
556 }
557
558 #[test]
559 fn island_after_compile_with_html_file_but_zero_island_refs_returns_early()
560 {
561 let dir = tempdir().unwrap();
565 let site = dir.path().join("site");
566 fs::create_dir_all(&site).unwrap();
567 fs::write(
568 site.join("page.html"),
569 "<html><body><p>nothing</p></body></html>",
570 )
571 .unwrap();
572
573 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
574 IslandPlugin.after_compile(&ctx).unwrap();
575 assert!(!site.join("_islands").exists());
576 }
577
578 #[test]
586 fn loader_tag_is_prefixed_for_sub_path_deploys() {
587 use crate::cmd::SsgConfig;
588
589 let dir = tempdir().unwrap();
590 let config = SsgConfig {
591 base_url: "https://example.com/velocity".to_string(),
592 ..SsgConfig::default()
593 };
594 let ctx = PluginContext::with_config(
595 dir.path(),
596 dir.path(),
597 dir.path(),
598 dir.path(),
599 config,
600 );
601
602 let out = IslandPlugin::new()
603 .transform_html(
604 "<html><body><ssg-island component=\"c\"></ssg-island></body></html>",
605 Path::new("index.html"),
606 &ctx,
607 )
608 .unwrap();
609
610 assert!(
611 out.contains("src=\"/velocity/_islands/ssg-island.js\""),
612 "loader must carry the sub-path prefix: {out}"
613 );
614 assert!(
615 !out.contains("src=\"/_islands/"),
616 "no unprefixed reference may survive: {out}"
617 );
618 }
619
620 #[test]
622 fn loader_tag_is_unprefixed_at_the_domain_root() {
623 use crate::cmd::SsgConfig;
624
625 let dir = tempdir().unwrap();
626 let config = SsgConfig {
627 base_url: "https://example.com".to_string(),
628 ..SsgConfig::default()
629 };
630 let ctx = PluginContext::with_config(
631 dir.path(),
632 dir.path(),
633 dir.path(),
634 dir.path(),
635 config,
636 );
637
638 let out = IslandPlugin::new()
639 .transform_html(
640 "<html><body><ssg-island component=\"c\"></ssg-island></body></html>",
641 Path::new("index.html"),
642 &ctx,
643 )
644 .unwrap();
645
646 assert!(out.contains("src=\"/_islands/ssg-island.js\""), "{out}");
647 }
648
649 #[test]
650 fn transform_html_skips_when_loader_already_injected() {
651 let dir = tempdir().unwrap();
652 let ctx =
653 PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
654 let html = "<body><ssg-island component=\"c\"></ssg-island>\
655 <script src=\"/_islands/ssg-island.js\"></script></body>";
656 let out = IslandPlugin
657 .transform_html(html, Path::new("i.html"), &ctx)
658 .unwrap();
659 assert_eq!(out, html);
660 }
661
662 #[test]
663 fn transform_html_appends_loader_when_body_close_missing() {
664 let dir = tempdir().unwrap();
665 let ctx =
666 PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
667 let html = "<ssg-island component=\"c\"></ssg-island>";
668 let out = IslandPlugin
669 .transform_html(html, Path::new("i.html"), &ctx)
670 .unwrap();
671 assert!(out.ends_with("</script>\n"));
672 assert!(out.starts_with(html));
673 }
674
675 #[test]
680 fn extract_ignores_empty_component_name() {
681 let html = "<ssg-island component=\"\"></ssg-island>";
682 assert!(extract_island_components(html).is_empty());
683 }
684
685 #[test]
686 fn extract_ignores_unterminated_component_value() {
687 let html = "<ssg-island component=\"counter></ssg-island>";
690 assert!(extract_island_components(html).is_empty());
691 }
692
693 #[test]
694 fn extract_stops_on_unterminated_tag() {
695 let html = "<ssg-island component=\"counter\"";
696 assert!(extract_island_components(html).is_empty());
697 }
698
699 #[test]
700 fn extract_ignores_tag_without_component_attribute() {
701 let html = "<ssg-island hydrate=\"idle\"></ssg-island>";
705 assert!(extract_island_components(html).is_empty());
706 }
707
708 fn island_site(dir: &Path) -> std::path::PathBuf {
714 let site = dir.join("site");
715 fs::create_dir_all(&site).unwrap();
716 fs::write(
717 site.join("index.html"),
718 "<html><body><ssg-island component=\"counter\"></ssg-island></body></html>",
719 )
720 .unwrap();
721 site
722 }
723
724 #[test]
725 fn after_compile_without_source_islands_dir_still_writes_loader() {
726 let dir = tempdir().unwrap();
727 let site = island_site(dir.path());
728 let content = dir.path().join("nested").join("content");
730 fs::create_dir_all(&content).unwrap();
731 let ctx = PluginContext::new(&content, dir.path(), &site, dir.path());
732
733 IslandPlugin.after_compile(&ctx).unwrap();
734 assert!(site.join("_islands/ssg-island.js").exists());
735 assert!(!site.join("_islands/counter.js").exists());
736 }
737
738 #[test]
739 fn after_compile_skips_component_without_source_bundle() {
740 let dir = tempdir().unwrap();
741 let site = island_site(dir.path());
742 let content = dir.path().join("content");
743 fs::create_dir_all(&content).unwrap();
744 fs::create_dir_all(dir.path().join("islands")).unwrap();
746 let ctx = PluginContext::new(&content, dir.path(), &site, dir.path());
747
748 IslandPlugin.after_compile(&ctx).unwrap();
749 assert!(!site.join("_islands/counter.js").exists());
750 assert!(site.join("_islands/manifest.json").exists());
751 }
752
753 #[test]
754 fn after_compile_fails_when_islands_dir_squatted_by_file() {
755 let dir = tempdir().unwrap();
756 let site = island_site(dir.path());
757 fs::write(site.join("_islands"), "not a dir").unwrap();
758 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
759 let err = IslandPlugin.after_compile(&ctx).unwrap_err();
760 assert!(!format!("{err}").is_empty());
761 }
762
763 #[test]
764 fn after_compile_fails_when_bundle_dst_squatted_by_dir() {
765 let dir = tempdir().unwrap();
766 let site = island_site(dir.path());
767 let content = dir.path().join("content");
768 fs::create_dir_all(&content).unwrap();
769 fs::create_dir_all(dir.path().join("islands")).unwrap();
770 fs::write(dir.path().join("islands/counter.js"), "export {}").unwrap();
771 fs::create_dir_all(site.join("_islands/counter.js")).unwrap();
773 let ctx = PluginContext::new(&content, dir.path(), &site, dir.path());
774
775 let err = IslandPlugin.after_compile(&ctx).unwrap_err();
776 assert!(!format!("{err}").is_empty());
777 }
778
779 #[test]
780 fn after_compile_fails_when_manifest_squatted_by_dir() {
781 let dir = tempdir().unwrap();
782 let site = island_site(dir.path());
783 let content = dir.path().join("content");
784 fs::create_dir_all(&content).unwrap();
785 fs::create_dir_all(site.join("_islands/manifest.json")).unwrap();
786 let ctx = PluginContext::new(&content, dir.path(), &site, dir.path());
787
788 let err = IslandPlugin.after_compile(&ctx).unwrap_err();
789 assert!(!format!("{err}").is_empty());
790 }
791
792 #[test]
793 fn after_compile_fails_when_loader_squatted_by_dir() {
794 let dir = tempdir().unwrap();
795 let site = island_site(dir.path());
796 let content = dir.path().join("content");
797 fs::create_dir_all(&content).unwrap();
798 fs::create_dir_all(site.join("_islands/ssg-island.js")).unwrap();
799 let ctx = PluginContext::new(&content, dir.path(), &site, dir.path());
800
801 let err = IslandPlugin.after_compile(&ctx).unwrap_err();
802 assert!(!format!("{err}").is_empty());
803 }
804
805 #[test]
806 #[cfg(unix)]
807 fn after_compile_fails_when_html_is_unreadable() {
808 use std::os::unix::fs::PermissionsExt;
809 let dir = tempdir().unwrap();
810 let site = island_site(dir.path());
811 let html = site.join("index.html");
812 fs::set_permissions(&html, fs::Permissions::from_mode(0o000)).unwrap();
813
814 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
815 let res = IslandPlugin.after_compile(&ctx);
816
817 let _ = fs::set_permissions(&html, fs::Permissions::from_mode(0o644));
818 if let Err(e) = res {
820 assert!(!format!("{e}").is_empty());
821 }
822 }
823
824 #[test]
829 fn inject_island_loader_errors_on_missing_file() {
830 let dir = tempdir().unwrap();
831 assert!(inject_island_loader(&dir.path().join("nope.html")).is_err());
832 }
833
834 #[test]
835 fn inject_island_loader_appends_without_body_close() {
836 let dir = tempdir().unwrap();
837 let page = dir.path().join("p.html");
838 fs::write(&page, "<p>no body close</p>").unwrap();
839 inject_island_loader(&page).unwrap();
840 let out = fs::read_to_string(&page).unwrap();
841 assert!(out.ends_with("</script>\n"));
842 }
843
844 #[test]
845 #[cfg(unix)]
846 fn inject_island_loader_write_error_on_readonly_file() {
847 use std::os::unix::fs::PermissionsExt;
848 let dir = tempdir().unwrap();
849 let page = dir.path().join("p.html");
850 fs::write(&page, "<body></body>").unwrap();
851 fs::set_permissions(&page, fs::Permissions::from_mode(0o444)).unwrap();
852
853 let res = inject_island_loader(&page);
854
855 let _ = fs::set_permissions(&page, fs::Permissions::from_mode(0o644));
856 if let Err(e) = res {
857 assert!(!format!("{e}").is_empty());
858 }
859 }
860}