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_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    /// See <https://github.com/wasmerio/wasmer/issues/6835>.
439    #[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    /// Fixes <https://github.com/wasmerio/wasmer/issues/3789>
510    #[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                // the program name from
531                "program-name",
532                // from the WEBC's annotations
533                "hard",
534                "coded",
535                "args",
536                // from the user
537                "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                    // Note: Some timestamps aren't available on MUSL and will
928                    // default to zero.
929                    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}