wasmer_wasix/runners/
wasi_common.rs

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