1use std::{
2 collections::HashMap,
3 ffi::OsString,
4 path::{Component, Path, PathBuf},
5 sync::Arc,
6};
7
8use anyhow::{Context, Error};
9use tokio::runtime::Handle;
10use virtual_fs::{
11 ArcFileSystem, ExactMountConflictMode, FileSystem, MountFileSystem, OverlayFileSystem,
12 RootFileSystemBuilder, TmpFileSystem, limiter::DynFsMemoryLimiter,
13};
14use webc::metadata::annotations::Wasi as WasiAnnotation;
15
16use crate::{
17 WasiEnvBuilder,
18 bin_factory::{BinaryPackage, BinaryPackageMounts},
19 capabilities::Capabilities,
20 fs::WasiFsRoot,
21 journal::{DynJournal, DynReadableJournal, SnapshotTrigger},
22};
23
24pub const MAPPED_CURRENT_DIR_DEFAULT_PATH: &str = "/home";
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum ExistingMountConflictBehavior {
28 Fail,
29 #[default]
30 Override,
31}
32
33#[derive(Debug, Clone)]
34pub struct MappedCommand {
35 pub alias: String,
37 pub target: String,
39}
40
41#[derive(Debug, Default, Clone)]
42pub(crate) struct CommonWasiOptions {
43 pub(crate) entry_function: Option<String>,
44 pub(crate) args: Vec<String>,
45 pub(crate) env: HashMap<String, String>,
46 pub(crate) forward_host_env: bool,
47 pub(crate) mapped_host_commands: Vec<MappedCommand>,
48 pub(crate) mounts: Vec<MountedDirectory>,
49 pub(crate) is_home_mapped: bool,
50 pub(crate) injected_packages: Vec<BinaryPackage>,
51 pub(crate) capabilities: Capabilities,
52 pub(crate) read_only_journals: Vec<Arc<DynReadableJournal>>,
53 pub(crate) writable_journals: Vec<Arc<DynJournal>>,
54 pub(crate) snapshot_on: Vec<SnapshotTrigger>,
55 pub(crate) snapshot_interval: Option<std::time::Duration>,
56 pub(crate) stop_running_after_snapshot: bool,
57 pub(crate) skip_stdio_during_bootstrap: bool,
58 pub(crate) current_dir: Option<PathBuf>,
59 pub(crate) existing_mount_conflict_behavior: ExistingMountConflictBehavior,
60}
61
62impl CommonWasiOptions {
63 pub(crate) fn prepare_webc_env(
64 &self,
65 builder: &mut WasiEnvBuilder,
66 container_mounts: Option<&BinaryPackageMounts>,
67 wasi: &WasiAnnotation,
68 root_fs: Option<WasiFsRoot>,
69 ) -> Result<(), anyhow::Error> {
70 if let Some(ref entry_function) = self.entry_function {
71 builder.set_entry_function(entry_function);
72 }
73
74 let root_fs = root_fs.unwrap_or_else(|| {
75 let mapped_dirs = self
76 .mounts
77 .iter()
78 .map(|d| d.guest.as_str())
79 .collect::<Vec<_>>();
80 WasiFsRoot::from_filesystem(Arc::new(
81 RootFileSystemBuilder::default().build_tmp_ext(&mapped_dirs),
82 ))
83 });
84 let fs = prepare_filesystem(
85 root_fs
86 .root()
87 .filesystem_at(Path::new("/"))
88 .context("root fs is missing a / mount")?,
89 root_fs.memory_limiter(),
90 &self.mounts,
91 container_mounts,
92 self.existing_mount_conflict_behavior,
93 )?;
94
95 if self.mounts.iter().all(|m| m.guest != ".") {
97 let path = builder.get_current_dir().unwrap_or(PathBuf::from("/"));
99 builder.add_preopen_build(|p| {
100 p.directory(&path)
101 .alias(".")
102 .read(true)
103 .write(true)
104 .create(true)
105 })?;
106 }
107
108 builder.add_preopen_dir("/")?;
109
110 builder.set_fs_root(fs);
111
112 for pkg in &self.injected_packages {
113 builder.add_webc(pkg.clone());
114 }
115
116 let mapped_cmds = self
117 .mapped_host_commands
118 .iter()
119 .map(|c| (c.alias.as_str(), c.target.as_str()));
120 builder.add_mapped_commands(mapped_cmds);
121
122 self.populate_env(wasi, builder);
123 self.populate_args(wasi, builder);
124
125 *builder.capabilities_mut() = self.capabilities.clone();
126
127 #[cfg(feature = "journal")]
128 {
129 for journal in &self.read_only_journals {
130 builder.add_read_only_journal(journal.clone());
131 }
132 for journal in &self.writable_journals {
133 builder.add_writable_journal(journal.clone());
134 }
135 for trigger in &self.snapshot_on {
136 builder.add_snapshot_trigger(*trigger);
137 }
138 if let Some(interval) = self.snapshot_interval {
139 builder.with_snapshot_interval(interval);
140 }
141 builder.with_stop_running_after_snapshot(self.stop_running_after_snapshot);
142 }
143
144 Ok(())
145 }
146
147 fn populate_env(&self, wasi: &WasiAnnotation, builder: &mut WasiEnvBuilder) {
148 for item in wasi.env.as_deref().unwrap_or_default() {
149 match item.split_once('=') {
153 Some((k, v)) => {
154 builder.add_env(k, v);
155 }
156 None => {
157 builder.add_env(item, String::new());
158 }
159 }
160 }
161
162 if self.forward_host_env {
163 builder.add_envs(os_env_vars(std::env::vars_os()));
164 }
165
166 builder.add_envs(self.env.clone());
167 }
168
169 fn populate_args(&self, wasi: &WasiAnnotation, builder: &mut WasiEnvBuilder) {
170 if let Some(main_args) = &wasi.main_args {
171 builder.add_args(main_args);
172 }
173
174 builder.add_args(&self.args);
175 }
176}
177
178fn os_env_vars(
186 vars: impl IntoIterator<Item = (OsString, OsString)>,
187) -> impl Iterator<Item = (Vec<u8>, Vec<u8>)> {
188 vars.into_iter()
189 .map(|(name, value)| (name.into_encoded_bytes(), value.into_encoded_bytes()))
190}
191
192fn normalized_mount_path(guest_path: &str) -> Result<PathBuf, Error> {
193 let mut guest_path = PathBuf::from(guest_path);
194
195 if guest_path.is_relative() {
196 guest_path = apply_relative_path_mounting_hack(&guest_path);
197 }
198
199 let mut normalized = PathBuf::from("/");
200 for component in guest_path.components() {
201 match component {
202 Component::RootDir => normalized = PathBuf::from("/"),
203 Component::CurDir => {}
204 Component::ParentDir => {
205 if normalized.as_os_str() == "/" {
206 anyhow::bail!(
207 "Invalid guest mount path \"{}\": parent traversal escapes the virtual root",
208 guest_path.display()
209 );
210 }
211 normalized.pop();
212 }
213 Component::Normal(part) => normalized.push(part),
214 Component::Prefix(_) => {
215 anyhow::bail!(
216 "Invalid guest mount path \"{}\": platform-specific prefixes are not supported",
217 guest_path.display()
218 );
219 }
220 }
221 }
222
223 Ok(normalized)
224}
225
226fn prepare_filesystem(
227 base_root: Arc<dyn FileSystem + Send + Sync>,
228 memory_limiter: Option<&DynFsMemoryLimiter>,
229 mounted_dirs: &[MountedDirectory],
230 container_mounts: Option<&BinaryPackageMounts>,
231 conflict_behavior: ExistingMountConflictBehavior,
232) -> Result<WasiFsRoot, Error> {
233 let mut root_layers: Vec<Arc<dyn FileSystem + Send + Sync>> = Vec::new();
234 let mount_fs = MountFileSystem::new();
235
236 for MountedDirectory { guest, fs } in mounted_dirs {
237 let guest_path = normalized_mount_path(guest)?;
238 tracing::debug!(guest=%guest_path.display(), "Mounting");
239
240 if guest_path == Path::new("/") {
241 root_layers.push(fs.clone());
242 } else {
243 match conflict_behavior {
244 ExistingMountConflictBehavior::Fail => mount_fs
245 .mount(&guest_path, fs.clone())
246 .with_context(|| format!("Unable to mount \"{}\"", guest_path.display()))?,
247 ExistingMountConflictBehavior::Override => mount_fs
248 .set_mount(&guest_path, fs.clone())
249 .with_context(|| format!("Unable to mount \"{}\"", guest_path.display()))?,
250 }
251 }
252 }
253
254 let Some(container) = container_mounts else {
255 let root_mount: Arc<dyn FileSystem + Send + Sync> = if root_layers.is_empty() {
256 base_root
257 } else {
258 Arc::new(OverlayFileSystem::new(
259 ArcFileSystem::new(base_root),
260 root_layers,
261 ))
262 };
263 mount_fs.mount(Path::new("/"), root_mount)?;
264
265 return Ok(
266 WasiFsRoot::from_mount_fs(mount_fs).with_memory_limiter_opt(memory_limiter.cloned())
267 );
268 };
269
270 if let Some(container_root) = &container.root_layer {
271 root_layers.push(writable_package_mount(
272 container_root.clone(),
273 memory_limiter,
274 ));
275 }
276
277 let root_mount: Arc<dyn FileSystem + Send + Sync> = if root_layers.is_empty() {
278 base_root
279 } else {
280 Arc::new(OverlayFileSystem::new(
281 ArcFileSystem::new(base_root),
282 root_layers,
283 ))
284 };
285
286 mount_fs.mount(Path::new("/"), root_mount)?;
287 let import_mode = match conflict_behavior {
288 ExistingMountConflictBehavior::Fail => ExactMountConflictMode::Fail,
289 ExistingMountConflictBehavior::Override => ExactMountConflictMode::KeepExisting,
290 };
291 let mut skipped_subtree: Option<PathBuf> = None;
292 for mount in &container.mounts {
293 if skipped_subtree
294 .as_ref()
295 .is_some_and(|prefix| mount.guest_path.starts_with(prefix))
296 {
297 continue;
298 }
299
300 match import_mode {
301 ExactMountConflictMode::Fail => {
302 mount_fs
303 .mount_with_source(
304 &mount.guest_path,
305 &mount.source_path,
306 writable_package_mount(mount.fs.clone(), memory_limiter),
307 )
308 .with_context(|| {
309 format!(
310 "Unable to merge container mount \"{}\" into the prepared filesystem",
311 mount.guest_path.display()
312 )
313 })?;
314 }
315 ExactMountConflictMode::KeepExisting => {
316 if mount_fs.filesystem_at(&mount.guest_path).is_some() {
317 skipped_subtree = Some(mount.guest_path.clone());
318 continue;
319 }
320
321 mount_fs
322 .mount_with_source(
323 &mount.guest_path,
324 &mount.source_path,
325 writable_package_mount(mount.fs.clone(), memory_limiter),
326 )
327 .with_context(|| {
328 format!(
329 "Unable to merge container mount \"{}\" into the prepared filesystem",
330 mount.guest_path.display()
331 )
332 })?;
333 }
334 ExactMountConflictMode::ReplaceExisting => unreachable!("not used here"),
335 }
336 }
337
338 Ok(WasiFsRoot::from_mount_fs(mount_fs).with_memory_limiter_opt(memory_limiter.cloned()))
339}
340
341fn writable_package_mount(
342 fs: Arc<dyn FileSystem + Send + Sync>,
343 memory_limiter: Option<&DynFsMemoryLimiter>,
344) -> Arc<dyn FileSystem + Send + Sync> {
345 let upper = TmpFileSystem::new();
346 if let Some(memory_limiter) = memory_limiter {
347 upper.set_memory_limiter(memory_limiter.clone());
348 }
349
350 Arc::new(OverlayFileSystem::new(upper, [ArcFileSystem::new(fs)]))
351}
352
353fn apply_relative_path_mounting_hack(original: &Path) -> PathBuf {
365 debug_assert!(original.is_relative());
366
367 let root = Path::new("/");
368 let mapped_path = if original == Path::new(".") {
369 root.to_path_buf()
370 } else {
371 root.join(original)
372 };
373
374 tracing::debug!(
375 original_path=%original.display(),
376 remapped_path=%mapped_path.display(),
377 "Remapping a relative path"
378 );
379
380 mapped_path
381}
382
383#[derive(Debug, Clone)]
384pub struct MountedDirectory {
385 pub guest: String,
386 pub fs: Arc<dyn FileSystem + Send + Sync>,
387}
388
389#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
398pub struct MappedDirectory {
399 pub host: std::path::PathBuf,
401 pub guest: String,
404}
405
406impl From<MappedDirectory> for MountedDirectory {
407 fn from(value: MappedDirectory) -> Self {
408 cfg_if::cfg_if! {
409 if #[cfg(feature = "host-fs")] {
410 let MappedDirectory { host, guest } = value;
411 let fs: Arc<dyn FileSystem + Send + Sync> =
412 Arc::new(virtual_fs::host_fs::FileSystem::new(Handle::current(), host).unwrap());
413
414 MountedDirectory { guest, fs }
415 } else {
416 unreachable!("The `host-fs` feature needs to be enabled to map {value:?}")
417 }
418 }
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use std::{
425 sync::{
426 Arc,
427 atomic::{AtomicUsize, Ordering},
428 },
429 time::SystemTime,
430 };
431
432 use tempfile::TempDir;
433 use virtual_fs::TmpFileSystem;
434 use virtual_fs::{DirEntry, FileType, FsError, Metadata, limiter::FsMemoryLimiter};
435
436 use super::*;
437
438 #[cfg(unix)]
440 #[test]
441 fn issue_6835_non_utf8_host_env_vars_are_forwarded_as_raw_bytes() {
442 use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
443
444 let vars = [
445 (
446 OsStr::from_bytes(b"VALID").to_os_string(),
447 OsStr::from_bytes(b"ok").to_os_string(),
448 ),
449 (
450 OsStr::from_bytes(b"INVALID").to_os_string(),
451 OsStr::from_bytes(b"V\xffW").to_os_string(),
452 ),
453 ];
454
455 let mut builder = WasiEnvBuilder::new("test");
456 builder.add_envs(os_env_vars(vars));
457
458 assert_eq!(
459 builder.get_env(),
460 [
461 ("VALID".to_string(), b"ok".to_vec()),
462 ("INVALID".to_string(), b"V\xffW".to_vec()),
463 ]
464 );
465 }
466
467 fn base_root(root_fs: &MountFileSystem) -> Arc<dyn FileSystem + Send + Sync> {
468 root_fs.filesystem_at(Path::new("/")).unwrap()
469 }
470
471 fn package_mounts(fs: MountFileSystem) -> BinaryPackageMounts {
472 BinaryPackageMounts::from_mount_fs(fs)
473 }
474
475 const PYTHON: &[u8] =
476 include_bytes!("../../../../wasmer-test-files/examples/python-0.1.0.wasmer");
477
478 #[derive(Debug)]
479 struct CountingLimiter {
480 used: AtomicUsize,
481 limit: usize,
482 }
483
484 impl CountingLimiter {
485 fn new(limit: usize) -> Self {
486 Self {
487 used: AtomicUsize::new(0),
488 limit,
489 }
490 }
491 }
492
493 impl FsMemoryLimiter for CountingLimiter {
494 fn on_grow(&self, grown_bytes: usize) -> Result<(), FsError> {
495 let new_total = self.used.fetch_add(grown_bytes, Ordering::SeqCst) + grown_bytes;
496 if new_total > self.limit {
497 self.used.fetch_sub(grown_bytes, Ordering::SeqCst);
498 return Err(FsError::StorageFull);
499 }
500
501 Ok(())
502 }
503
504 fn on_shrink(&self, shrunk_bytes: usize) {
505 self.used.fetch_sub(shrunk_bytes, Ordering::SeqCst);
506 }
507 }
508
509 #[tokio::test]
511 async fn mix_args_from_the_webc_and_user() {
512 let args = CommonWasiOptions {
513 args: vec!["extra".to_string(), "args".to_string()],
514 ..Default::default()
515 };
516 let mut builder = WasiEnvBuilder::new("program-name");
517 let mut annotations = WasiAnnotation::new("some-atom");
518 annotations.main_args = Some(vec![
519 "hard".to_string(),
520 "coded".to_string(),
521 "args".to_string(),
522 ]);
523
524 args.prepare_webc_env(&mut builder, None, &annotations, None)
525 .unwrap();
526
527 assert_eq!(
528 builder.get_args(),
529 [
530 "program-name",
532 "hard",
534 "coded",
535 "args",
536 "extra",
538 "args",
539 ]
540 );
541 }
542
543 #[tokio::test]
544 async fn mix_env_vars_from_the_webc_and_user() {
545 let args = CommonWasiOptions {
546 env: vec![("EXTRA".to_string(), "envs".to_string())]
547 .into_iter()
548 .collect(),
549 ..Default::default()
550 };
551 let mut builder = WasiEnvBuilder::new("python");
552 let mut annotations = WasiAnnotation::new("python");
553 annotations.env = Some(vec!["HARD_CODED=env-vars".to_string()]);
554
555 args.prepare_webc_env(&mut builder, None, &annotations, None)
556 .unwrap();
557
558 assert_eq!(
559 builder.get_env(),
560 [
561 ("HARD_CODED".to_string(), b"env-vars".to_vec()),
562 ("EXTRA".to_string(), b"envs".to_vec()),
563 ]
564 );
565 }
566
567 fn unix_timestamp_nanos(instant: SystemTime) -> Option<u64> {
568 let duration = instant.duration_since(SystemTime::UNIX_EPOCH).ok()?;
569 Some(duration.as_nanos() as u64)
570 }
571
572 #[tokio::test]
573 #[cfg_attr(not(feature = "host-fs"), ignore)]
574 async fn python_use_case() {
575 let temp = TempDir::new().unwrap();
576 let sub_dir = temp.path().join("path").join("to");
577 std::fs::create_dir_all(&sub_dir).unwrap();
578 std::fs::write(sub_dir.join("file.txt"), b"Hello, World!").unwrap();
579 let mapping = [MountedDirectory::from(MappedDirectory {
580 guest: "/home".to_string(),
581 host: sub_dir,
582 })];
583 let container = wasmer_package::utils::from_bytes(PYTHON).unwrap();
584 let webc_fs = virtual_fs::WebcVolumeFileSystem::mount_all(&container);
585 let mount_fs = MountFileSystem::new();
586 mount_fs.mount(Path::new("/"), Arc::new(webc_fs)).unwrap();
587
588 let root_fs = RootFileSystemBuilder::default().build();
589 let fs = prepare_filesystem(
590 base_root(&root_fs),
591 None,
592 &mapping,
593 Some(&package_mounts(mount_fs)),
594 ExistingMountConflictBehavior::Override,
595 )
596 .unwrap();
597
598 use virtual_fs::FileSystem;
599 assert!(fs.metadata("/home/file.txt".as_ref()).unwrap().is_file());
600 assert!(fs.metadata("lib".as_ref()).unwrap().is_dir());
601 assert!(
602 fs.metadata("lib/python3.6/collections/__init__.py".as_ref())
603 .unwrap()
604 .is_file()
605 );
606 assert!(
607 fs.metadata("lib/python3.6/encodings/__init__.py".as_ref())
608 .unwrap()
609 .is_file()
610 );
611 }
612
613 #[tokio::test]
614 async fn package_mount_paths_remain_writable() {
615 use virtual_fs::FileSystem;
616
617 let container = wasmer_package::utils::from_bytes(PYTHON).unwrap();
618 let pkg_mount = virtual_fs::WebcVolumeFileSystem::mount_all(&container);
619
620 let mount_fs = MountFileSystem::new();
621 mount_fs
622 .mount(Path::new("/python"), Arc::new(pkg_mount))
623 .unwrap();
624
625 let root_fs = RootFileSystemBuilder::default().build();
626 let fs = prepare_filesystem(
627 base_root(&root_fs),
628 None,
629 &[],
630 Some(&package_mounts(mount_fs)),
631 ExistingMountConflictBehavior::Override,
632 )
633 .unwrap();
634
635 fs.create_dir(Path::new("/python/custom")).unwrap();
636 fs.new_open_options()
637 .create(true)
638 .write(true)
639 .open(Path::new("/python/custom/sitecustomize.py"))
640 .unwrap();
641
642 assert!(
643 fs.metadata(Path::new("/python/custom/sitecustomize.py"))
644 .unwrap()
645 .is_file()
646 );
647 assert!(
648 fs.metadata(Path::new("/python/lib/python3.6/collections/__init__.py"))
649 .unwrap()
650 .is_file()
651 );
652 }
653
654 #[tokio::test]
655 async fn package_mount_symlinks_remain_writable() {
656 use virtual_fs::FileSystem;
657
658 let container = wasmer_package::utils::from_bytes(PYTHON).unwrap();
659 let pkg_mount = virtual_fs::WebcVolumeFileSystem::mount_all(&container);
660
661 let mount_fs = MountFileSystem::new();
662 mount_fs
663 .mount(Path::new("/python"), Arc::new(pkg_mount))
664 .unwrap();
665
666 let root_fs = RootFileSystemBuilder::default().build();
667 let fs = prepare_filesystem(
668 base_root(&root_fs),
669 None,
670 &[],
671 Some(&package_mounts(mount_fs)),
672 ExistingMountConflictBehavior::Override,
673 )
674 .unwrap();
675
676 fs.create_symlink(
677 Path::new("lib/python3.6/collections"),
678 Path::new("/python/collections-link"),
679 )
680 .unwrap();
681
682 assert_eq!(
683 fs.readlink(Path::new("/python/collections-link")).unwrap(),
684 Path::new("lib/python3.6/collections")
685 );
686 assert!(
687 fs.symlink_metadata(Path::new("/python/collections-link"))
688 .unwrap()
689 .ft
690 .is_symlink()
691 );
692 }
693
694 #[tokio::test]
695 async fn user_mounts_override_package_mounts_when_configured() {
696 use virtual_fs::FileSystem;
697
698 let user_mount = TmpFileSystem::new();
699 user_mount
700 .new_open_options()
701 .create(true)
702 .write(true)
703 .open(Path::new("/user.txt"))
704 .unwrap();
705
706 let package_mount = TmpFileSystem::new();
707 package_mount
708 .new_open_options()
709 .create(true)
710 .write(true)
711 .open(Path::new("/pkg.txt"))
712 .unwrap();
713
714 let mounted_dirs = [MountedDirectory {
715 guest: "/python".to_string(),
716 fs: Arc::new(user_mount),
717 }];
718
719 let container_mounts = MountFileSystem::new();
720 container_mounts
721 .mount(Path::new("/python"), Arc::new(package_mount))
722 .unwrap();
723
724 let root_fs = RootFileSystemBuilder::default().build();
725 let fs = prepare_filesystem(
726 base_root(&root_fs),
727 None,
728 &mounted_dirs,
729 Some(&package_mounts(container_mounts)),
730 ExistingMountConflictBehavior::Override,
731 )
732 .unwrap();
733
734 assert!(
735 fs.metadata(Path::new("/python/user.txt"))
736 .unwrap()
737 .is_file()
738 );
739 assert_eq!(
740 fs.metadata(Path::new("/python/pkg.txt")),
741 Err(virtual_fs::FsError::EntryNotFound)
742 );
743 }
744
745 #[tokio::test]
746 async fn conflicting_mounts_fail_when_configured() {
747 let user_mount = TmpFileSystem::new();
748 let package_mount = TmpFileSystem::new();
749
750 let mounted_dirs = [MountedDirectory {
751 guest: "/python".to_string(),
752 fs: Arc::new(user_mount),
753 }];
754
755 let container_mounts = MountFileSystem::new();
756 container_mounts
757 .mount(Path::new("/python"), Arc::new(package_mount))
758 .unwrap();
759
760 let root_fs = RootFileSystemBuilder::default().build();
761 let error = prepare_filesystem(
762 base_root(&root_fs),
763 None,
764 &mounted_dirs,
765 Some(&package_mounts(container_mounts)),
766 ExistingMountConflictBehavior::Fail,
767 )
768 .unwrap_err();
769
770 assert!(
771 error
772 .to_string()
773 .contains("Unable to merge container mount \"/python\""),
774 "{error:#}"
775 );
776 }
777
778 #[tokio::test]
779 async fn root_mounts_are_composed_even_in_fail_mode() {
780 use virtual_fs::FileSystem;
781
782 let root_mount = TmpFileSystem::new();
783 root_mount
784 .new_open_options()
785 .create(true)
786 .write(true)
787 .open(Path::new("/user.txt"))
788 .unwrap();
789
790 let mounted_dirs = [MountedDirectory {
791 guest: "/".to_string(),
792 fs: Arc::new(root_mount),
793 }];
794
795 let container_mounts = MountFileSystem::new();
796 let container_root = TmpFileSystem::new();
797 container_root
798 .new_open_options()
799 .create(true)
800 .write(true)
801 .open(Path::new("/pkg.txt"))
802 .unwrap();
803 container_mounts
804 .mount(Path::new("/"), Arc::new(container_root))
805 .unwrap();
806
807 let root_fs = RootFileSystemBuilder::default().build();
808 let fs = prepare_filesystem(
809 base_root(&root_fs),
810 None,
811 &mounted_dirs,
812 Some(&package_mounts(container_mounts)),
813 ExistingMountConflictBehavior::Fail,
814 )
815 .unwrap();
816
817 assert!(fs.metadata(Path::new("/user.txt")).unwrap().is_file());
818 assert!(fs.metadata(Path::new("/pkg.txt")).unwrap().is_file());
819 }
820
821 #[tokio::test]
822 async fn multiple_root_mounts_are_composed() {
823 use virtual_fs::FileSystem;
824
825 let first_root = TmpFileSystem::new();
826 first_root
827 .new_open_options()
828 .create(true)
829 .write(true)
830 .open(Path::new("/first.txt"))
831 .unwrap();
832
833 let second_root = TmpFileSystem::new();
834 second_root
835 .new_open_options()
836 .create(true)
837 .write(true)
838 .open(Path::new("/second.txt"))
839 .unwrap();
840
841 let mounted_dirs = [
842 MountedDirectory {
843 guest: "/".to_string(),
844 fs: Arc::new(first_root),
845 },
846 MountedDirectory {
847 guest: "/".to_string(),
848 fs: Arc::new(second_root),
849 },
850 ];
851
852 let root_fs = RootFileSystemBuilder::default().build();
853 let fs = prepare_filesystem(
854 base_root(&root_fs),
855 None,
856 &mounted_dirs,
857 None,
858 ExistingMountConflictBehavior::Fail,
859 )
860 .unwrap();
861
862 assert!(fs.metadata(Path::new("/first.txt")).unwrap().is_file());
863 assert!(fs.metadata(Path::new("/second.txt")).unwrap().is_file());
864 }
865
866 #[tokio::test]
867 async fn prepared_filesystem_preserves_root_memory_limiter() {
868 let limiter: virtual_fs::limiter::DynFsMemoryLimiter = Arc::new(CountingLimiter::new(1));
869
870 let package_mount = TmpFileSystem::new();
871 let container_mounts = MountFileSystem::new();
872 container_mounts
873 .mount(Path::new("/python"), Arc::new(package_mount))
874 .unwrap();
875
876 let root_fs = RootFileSystemBuilder::default().build();
877 let fs = prepare_filesystem(
878 base_root(&root_fs),
879 Some(&limiter),
880 &[],
881 Some(&package_mounts(container_mounts)),
882 ExistingMountConflictBehavior::Override,
883 )
884 .unwrap();
885
886 assert!(fs.memory_limiter().is_some());
887 }
888
889 #[test]
890 fn invalid_guest_mount_paths_are_rejected() {
891 let error = normalized_mount_path("../../python").unwrap_err();
892 assert!(
893 error
894 .to_string()
895 .contains("parent traversal escapes the virtual root"),
896 "{error:#}"
897 );
898 }
899
900 #[tokio::test]
901 #[cfg_attr(not(feature = "host-fs"), ignore)]
902 async fn convert_mapped_directory_to_mounted_directory() {
903 let temp = TempDir::new().unwrap();
904 let dir = MappedDirectory {
905 guest: "/mnt/dir".to_string(),
906 host: temp.path().to_path_buf(),
907 };
908 let contents = "Hello, World!";
909 let file_txt = temp.path().join("file.txt");
910 std::fs::write(&file_txt, contents).unwrap();
911 let metadata = std::fs::metadata(&file_txt).unwrap();
912
913 let got = MountedDirectory::from(dir);
914
915 let directory_contents: Vec<_> = got
916 .fs
917 .read_dir("/".as_ref())
918 .unwrap()
919 .map(|entry| entry.unwrap())
920 .collect();
921 assert_eq!(
922 directory_contents,
923 vec![DirEntry {
924 path: PathBuf::from("/file.txt"),
925 metadata: Ok(Metadata {
926 ft: FileType::new_file(),
927 accessed: metadata
930 .accessed()
931 .ok()
932 .and_then(unix_timestamp_nanos)
933 .unwrap_or(0),
934 created: metadata
935 .created()
936 .ok()
937 .and_then(unix_timestamp_nanos)
938 .unwrap_or(0),
939 modified: metadata
940 .modified()
941 .ok()
942 .and_then(unix_timestamp_nanos)
943 .unwrap_or(0),
944 len: contents.len() as u64,
945 })
946 }]
947 );
948 }
949}