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_select! {
409 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 }
416 _ => {
417 unreachable!("The `host-fs` feature needs to be enabled to map {value:?}")
418 }
419 }
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use std::{
426 sync::{
427 Arc,
428 atomic::{AtomicUsize, Ordering},
429 },
430 time::SystemTime,
431 };
432
433 use tempfile::TempDir;
434 use virtual_fs::TmpFileSystem;
435 use virtual_fs::{DirEntry, FileType, FsError, Metadata, limiter::FsMemoryLimiter};
436
437 use super::*;
438
439 #[cfg(unix)]
441 #[test]
442 fn issue_6835_non_utf8_host_env_vars_are_forwarded_as_raw_bytes() {
443 use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
444
445 let vars = [
446 (
447 OsStr::from_bytes(b"VALID").to_os_string(),
448 OsStr::from_bytes(b"ok").to_os_string(),
449 ),
450 (
451 OsStr::from_bytes(b"INVALID").to_os_string(),
452 OsStr::from_bytes(b"V\xffW").to_os_string(),
453 ),
454 ];
455
456 let mut builder = WasiEnvBuilder::new("test");
457 builder.add_envs(os_env_vars(vars));
458
459 assert_eq!(
460 builder.get_env(),
461 [
462 ("VALID".to_string(), b"ok".to_vec()),
463 ("INVALID".to_string(), b"V\xffW".to_vec()),
464 ]
465 );
466 }
467
468 fn base_root(root_fs: &MountFileSystem) -> Arc<dyn FileSystem + Send + Sync> {
469 root_fs.filesystem_at(Path::new("/")).unwrap()
470 }
471
472 fn package_mounts(fs: MountFileSystem) -> BinaryPackageMounts {
473 BinaryPackageMounts::from_mount_fs(fs)
474 }
475
476 const PYTHON: &[u8] =
477 include_bytes!("../../../../wasmer-test-files/examples/python--python@3.13.5.webc");
478
479 #[derive(Debug)]
480 struct CountingLimiter {
481 used: AtomicUsize,
482 limit: usize,
483 }
484
485 impl CountingLimiter {
486 fn new(limit: usize) -> Self {
487 Self {
488 used: AtomicUsize::new(0),
489 limit,
490 }
491 }
492 }
493
494 impl FsMemoryLimiter for CountingLimiter {
495 fn on_grow(&self, grown_bytes: usize) -> Result<(), FsError> {
496 let new_total = self.used.fetch_add(grown_bytes, Ordering::SeqCst) + grown_bytes;
497 if new_total > self.limit {
498 self.used.fetch_sub(grown_bytes, Ordering::SeqCst);
499 return Err(FsError::StorageFull);
500 }
501
502 Ok(())
503 }
504
505 fn on_shrink(&self, shrunk_bytes: usize) {
506 self.used.fetch_sub(shrunk_bytes, Ordering::SeqCst);
507 }
508 }
509
510 #[tokio::test]
512 async fn mix_args_from_the_webc_and_user() {
513 let args = CommonWasiOptions {
514 args: vec!["extra".to_string(), "args".to_string()],
515 ..Default::default()
516 };
517 let mut builder = WasiEnvBuilder::new("program-name");
518 let mut annotations = WasiAnnotation::new("some-atom");
519 annotations.main_args = Some(vec![
520 "hard".to_string(),
521 "coded".to_string(),
522 "args".to_string(),
523 ]);
524
525 args.prepare_webc_env(&mut builder, None, &annotations, None)
526 .unwrap();
527
528 assert_eq!(
529 builder.get_args(),
530 [
531 "program-name",
533 "hard",
535 "coded",
536 "args",
537 "extra",
539 "args",
540 ]
541 );
542 }
543
544 #[tokio::test]
545 async fn mix_env_vars_from_the_webc_and_user() {
546 let args = CommonWasiOptions {
547 env: vec![
548 ("EXTRA".to_string(), "envs".to_string()),
549 ("HARD_CODED".to_string(), "user-override".to_string()),
550 ]
551 .into_iter()
552 .collect(),
553 ..Default::default()
554 };
555 let mut builder = WasiEnvBuilder::new("python");
556 let mut annotations = WasiAnnotation::new("python");
557 annotations.env = Some(vec!["HARD_CODED=env-vars".to_string()]);
558
559 args.prepare_webc_env(&mut builder, None, &annotations, None)
560 .unwrap();
561
562 assert_eq!(
563 builder.get_env(),
564 [
565 ("HARD_CODED".to_string(), b"user-override".to_vec()),
566 ("EXTRA".to_string(), b"envs".to_vec()),
567 ]
568 );
569 }
570
571 fn unix_timestamp_nanos(instant: SystemTime) -> Option<u64> {
572 let duration = instant.duration_since(SystemTime::UNIX_EPOCH).ok()?;
573 Some(duration.as_nanos() as u64)
574 }
575
576 #[tokio::test]
577 #[cfg_attr(not(feature = "host-fs"), ignore)]
578 async fn python_use_case() {
579 let temp = TempDir::new().unwrap();
580 let sub_dir = temp.path().join("path").join("to");
581 std::fs::create_dir_all(&sub_dir).unwrap();
582 std::fs::write(sub_dir.join("file.txt"), b"Hello, World!").unwrap();
583 let mapping = [MountedDirectory::from(MappedDirectory {
584 guest: "/home".to_string(),
585 host: sub_dir,
586 })];
587 let container = wasmer_package::utils::from_bytes(PYTHON).unwrap();
588 let webc_fs = virtual_fs::WebcVolumeFileSystem::mount_all(&container);
589 let mount_fs = MountFileSystem::new();
590 mount_fs.mount(Path::new("/"), Arc::new(webc_fs)).unwrap();
591
592 let root_fs = RootFileSystemBuilder::default().build();
593 let fs = prepare_filesystem(
594 base_root(&root_fs),
595 None,
596 &mapping,
597 Some(&package_mounts(mount_fs)),
598 ExistingMountConflictBehavior::Override,
599 )
600 .unwrap();
601
602 use virtual_fs::FileSystem;
603 assert!(fs.metadata("/home/file.txt".as_ref()).unwrap().is_file());
604 assert!(fs.metadata("lib".as_ref()).unwrap().is_dir());
605 assert!(
606 fs.metadata("lib/python3.13/collections/__init__.py".as_ref())
607 .unwrap()
608 .is_file()
609 );
610 assert!(
611 fs.metadata("lib/python3.13/encodings/__init__.py".as_ref())
612 .unwrap()
613 .is_file()
614 );
615 }
616
617 #[tokio::test]
618 async fn package_mount_paths_remain_writable() {
619 use virtual_fs::FileSystem;
620
621 let container = wasmer_package::utils::from_bytes(PYTHON).unwrap();
622 let pkg_mount = virtual_fs::WebcVolumeFileSystem::mount_all(&container);
623
624 let mount_fs = MountFileSystem::new();
625 mount_fs
626 .mount(Path::new("/python"), Arc::new(pkg_mount))
627 .unwrap();
628
629 let root_fs = RootFileSystemBuilder::default().build();
630 let fs = prepare_filesystem(
631 base_root(&root_fs),
632 None,
633 &[],
634 Some(&package_mounts(mount_fs)),
635 ExistingMountConflictBehavior::Override,
636 )
637 .unwrap();
638
639 fs.create_dir(Path::new("/python/custom")).unwrap();
640 fs.new_open_options()
641 .create(true)
642 .write(true)
643 .open(Path::new("/python/custom/sitecustomize.py"))
644 .unwrap();
645
646 assert!(
647 fs.metadata(Path::new("/python/custom/sitecustomize.py"))
648 .unwrap()
649 .is_file()
650 );
651 assert!(
652 fs.metadata(Path::new("/python/lib/python3.13/collections/__init__.py"))
653 .unwrap()
654 .is_file()
655 );
656 }
657
658 #[tokio::test]
659 async fn package_mount_symlinks_remain_writable() {
660 use virtual_fs::FileSystem;
661
662 let container = wasmer_package::utils::from_bytes(PYTHON).unwrap();
663 let pkg_mount = virtual_fs::WebcVolumeFileSystem::mount_all(&container);
664
665 let mount_fs = MountFileSystem::new();
666 mount_fs
667 .mount(Path::new("/python"), Arc::new(pkg_mount))
668 .unwrap();
669
670 let root_fs = RootFileSystemBuilder::default().build();
671 let fs = prepare_filesystem(
672 base_root(&root_fs),
673 None,
674 &[],
675 Some(&package_mounts(mount_fs)),
676 ExistingMountConflictBehavior::Override,
677 )
678 .unwrap();
679
680 fs.create_symlink(
681 Path::new("lib/python3.13/collections"),
682 Path::new("/python/collections-link"),
683 )
684 .unwrap();
685
686 assert_eq!(
687 fs.readlink(Path::new("/python/collections-link")).unwrap(),
688 Path::new("lib/python3.13/collections")
689 );
690 assert!(
691 fs.symlink_metadata(Path::new("/python/collections-link"))
692 .unwrap()
693 .ft
694 .is_symlink()
695 );
696 }
697
698 #[tokio::test]
699 async fn user_mounts_override_package_mounts_when_configured() {
700 use virtual_fs::FileSystem;
701
702 let user_mount = TmpFileSystem::new();
703 user_mount
704 .new_open_options()
705 .create(true)
706 .write(true)
707 .open(Path::new("/user.txt"))
708 .unwrap();
709
710 let package_mount = TmpFileSystem::new();
711 package_mount
712 .new_open_options()
713 .create(true)
714 .write(true)
715 .open(Path::new("/pkg.txt"))
716 .unwrap();
717
718 let mounted_dirs = [MountedDirectory {
719 guest: "/python".to_string(),
720 fs: Arc::new(user_mount),
721 }];
722
723 let container_mounts = MountFileSystem::new();
724 container_mounts
725 .mount(Path::new("/python"), Arc::new(package_mount))
726 .unwrap();
727
728 let root_fs = RootFileSystemBuilder::default().build();
729 let fs = prepare_filesystem(
730 base_root(&root_fs),
731 None,
732 &mounted_dirs,
733 Some(&package_mounts(container_mounts)),
734 ExistingMountConflictBehavior::Override,
735 )
736 .unwrap();
737
738 assert!(
739 fs.metadata(Path::new("/python/user.txt"))
740 .unwrap()
741 .is_file()
742 );
743 assert_eq!(
744 fs.metadata(Path::new("/python/pkg.txt")),
745 Err(virtual_fs::FsError::EntryNotFound)
746 );
747 }
748
749 #[tokio::test]
750 async fn conflicting_mounts_fail_when_configured() {
751 let user_mount = TmpFileSystem::new();
752 let package_mount = TmpFileSystem::new();
753
754 let mounted_dirs = [MountedDirectory {
755 guest: "/python".to_string(),
756 fs: Arc::new(user_mount),
757 }];
758
759 let container_mounts = MountFileSystem::new();
760 container_mounts
761 .mount(Path::new("/python"), Arc::new(package_mount))
762 .unwrap();
763
764 let root_fs = RootFileSystemBuilder::default().build();
765 let error = prepare_filesystem(
766 base_root(&root_fs),
767 None,
768 &mounted_dirs,
769 Some(&package_mounts(container_mounts)),
770 ExistingMountConflictBehavior::Fail,
771 )
772 .unwrap_err();
773
774 assert!(
775 error
776 .to_string()
777 .contains("Unable to merge container mount \"/python\""),
778 "{error:#}"
779 );
780 }
781
782 #[tokio::test]
783 async fn root_mounts_are_composed_even_in_fail_mode() {
784 use virtual_fs::FileSystem;
785
786 let root_mount = TmpFileSystem::new();
787 root_mount
788 .new_open_options()
789 .create(true)
790 .write(true)
791 .open(Path::new("/user.txt"))
792 .unwrap();
793
794 let mounted_dirs = [MountedDirectory {
795 guest: "/".to_string(),
796 fs: Arc::new(root_mount),
797 }];
798
799 let container_mounts = MountFileSystem::new();
800 let container_root = TmpFileSystem::new();
801 container_root
802 .new_open_options()
803 .create(true)
804 .write(true)
805 .open(Path::new("/pkg.txt"))
806 .unwrap();
807 container_mounts
808 .mount(Path::new("/"), Arc::new(container_root))
809 .unwrap();
810
811 let root_fs = RootFileSystemBuilder::default().build();
812 let fs = prepare_filesystem(
813 base_root(&root_fs),
814 None,
815 &mounted_dirs,
816 Some(&package_mounts(container_mounts)),
817 ExistingMountConflictBehavior::Fail,
818 )
819 .unwrap();
820
821 assert!(fs.metadata(Path::new("/user.txt")).unwrap().is_file());
822 assert!(fs.metadata(Path::new("/pkg.txt")).unwrap().is_file());
823 }
824
825 #[tokio::test]
826 async fn multiple_root_mounts_are_composed() {
827 use virtual_fs::FileSystem;
828
829 let first_root = TmpFileSystem::new();
830 first_root
831 .new_open_options()
832 .create(true)
833 .write(true)
834 .open(Path::new("/first.txt"))
835 .unwrap();
836
837 let second_root = TmpFileSystem::new();
838 second_root
839 .new_open_options()
840 .create(true)
841 .write(true)
842 .open(Path::new("/second.txt"))
843 .unwrap();
844
845 let mounted_dirs = [
846 MountedDirectory {
847 guest: "/".to_string(),
848 fs: Arc::new(first_root),
849 },
850 MountedDirectory {
851 guest: "/".to_string(),
852 fs: Arc::new(second_root),
853 },
854 ];
855
856 let root_fs = RootFileSystemBuilder::default().build();
857 let fs = prepare_filesystem(
858 base_root(&root_fs),
859 None,
860 &mounted_dirs,
861 None,
862 ExistingMountConflictBehavior::Fail,
863 )
864 .unwrap();
865
866 assert!(fs.metadata(Path::new("/first.txt")).unwrap().is_file());
867 assert!(fs.metadata(Path::new("/second.txt")).unwrap().is_file());
868 }
869
870 #[tokio::test]
871 async fn prepared_filesystem_preserves_root_memory_limiter() {
872 let limiter: virtual_fs::limiter::DynFsMemoryLimiter = Arc::new(CountingLimiter::new(1));
873
874 let package_mount = TmpFileSystem::new();
875 let container_mounts = MountFileSystem::new();
876 container_mounts
877 .mount(Path::new("/python"), Arc::new(package_mount))
878 .unwrap();
879
880 let root_fs = RootFileSystemBuilder::default().build();
881 let fs = prepare_filesystem(
882 base_root(&root_fs),
883 Some(&limiter),
884 &[],
885 Some(&package_mounts(container_mounts)),
886 ExistingMountConflictBehavior::Override,
887 )
888 .unwrap();
889
890 assert!(fs.memory_limiter().is_some());
891 }
892
893 #[test]
894 fn invalid_guest_mount_paths_are_rejected() {
895 let error = normalized_mount_path("../../python").unwrap_err();
896 assert!(
897 error
898 .to_string()
899 .contains("parent traversal escapes the virtual root"),
900 "{error:#}"
901 );
902 }
903
904 #[tokio::test]
905 #[cfg_attr(not(feature = "host-fs"), ignore)]
906 async fn convert_mapped_directory_to_mounted_directory() {
907 let temp = TempDir::new().unwrap();
908 let dir = MappedDirectory {
909 guest: "/mnt/dir".to_string(),
910 host: temp.path().to_path_buf(),
911 };
912 let contents = "Hello, World!";
913 let file_txt = temp.path().join("file.txt");
914 std::fs::write(&file_txt, contents).unwrap();
915 let metadata = std::fs::metadata(&file_txt).unwrap();
916
917 let got = MountedDirectory::from(dir);
918
919 let directory_contents: Vec<_> = got
920 .fs
921 .read_dir("/".as_ref())
922 .unwrap()
923 .map(|entry| entry.unwrap())
924 .collect();
925 assert_eq!(
926 directory_contents,
927 vec![DirEntry {
928 path: PathBuf::from("/file.txt"),
929 metadata: Ok(Metadata {
930 ft: FileType::new_file(),
931 accessed: metadata
934 .accessed()
935 .ok()
936 .and_then(unix_timestamp_nanos)
937 .unwrap_or(0),
938 created: metadata
939 .created()
940 .ok()
941 .and_then(unix_timestamp_nanos)
942 .unwrap_or(0),
943 modified: metadata
944 .modified()
945 .ok()
946 .and_then(unix_timestamp_nanos)
947 .unwrap_or(0),
948 len: contents.len() as u64,
949 })
950 }]
951 );
952 }
953}