Skip to main content

wasmer_wasix/runners/
wasi_common.rs

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    /// The new alias.
36    pub alias: String,
37    /// The original command.
38    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        // TODO: What's a preopen for '.' supposed to mean anyway? Why do we need it?
96        if self.mounts.iter().all(|m| m.guest != ".") {
97            // The user hasn't mounted "." to anything, so let's map it to "/"
98            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            // TODO(Michael-F-Bryan): Convert "wasi.env" in the webc crate from an
150            // Option<Vec<String>> to a HashMap<String, String> so we avoid this
151            // string.split() business
152            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
178// type ContainerFs =
179//     OverlayFileSystem<TmpFileSystem, [RelativeOrAbsolutePathHack<Arc<dyn FileSystem>>; 1]>;
180
181/// Turn host environment variables into raw byte pairs.
182///
183/// [`std::env::vars`] panics on entries that are not valid UTF-8, while both
184/// unix and WASI environment variables are byte strings.
185fn 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
353/// HACK: We need this so users can mount host directories at relative paths.
354/// This assumes that the current directory when a runner starts will be "/", so
355/// instead of mounting to a relative path, we just mount to "/$path".
356///
357/// This isn't really a long-term solution because there is no guarantee what
358/// the current directory will be. The WASI spec also doesn't require the
359/// current directory to be part of the "main" filesystem at all, we really
360/// *should* be mounting to a relative directory but that isn't supported by our
361/// virtual fs layer.
362///
363/// See <https://github.com/wasmerio/wasmer/issues/3794> for more.
364fn 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/// A directory that should be mapped from the host filesystem into a WASI
390/// instance (the "guest").
391///
392/// # Panics
393///
394/// Converting a [`MappedDirectory`] to a [`MountedDirectory`] requires enabling
395/// the `host-fs` feature flag. Using the [`From`] implementation without
396/// enabling this feature will result in a runtime panic.
397#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
398pub struct MappedDirectory {
399    /// The absolute path for a directory on the host filesystem.
400    pub host: std::path::PathBuf,
401    /// The absolute path specifying where the host directory should be mounted
402    /// inside the guest.
403    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    /// See <https://github.com/wasmerio/wasmer/issues/6835>.
440    #[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    /// Fixes <https://github.com/wasmerio/wasmer/issues/3789>
511    #[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                // the program name from
532                "program-name",
533                // from the WEBC's annotations
534                "hard",
535                "coded",
536                "args",
537                // from the user
538                "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                    // Note: Some timestamps aren't available on MUSL and will
932                    // default to zero.
933                    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}