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-0.1.0.wasmer");
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![("EXTRA".to_string(), "envs".to_string())]
548                .into_iter()
549                .collect(),
550            ..Default::default()
551        };
552        let mut builder = WasiEnvBuilder::new("python");
553        let mut annotations = WasiAnnotation::new("python");
554        annotations.env = Some(vec!["HARD_CODED=env-vars".to_string()]);
555
556        args.prepare_webc_env(&mut builder, None, &annotations, None)
557            .unwrap();
558
559        assert_eq!(
560            builder.get_env(),
561            [
562                ("HARD_CODED".to_string(), b"env-vars".to_vec()),
563                ("EXTRA".to_string(), b"envs".to_vec()),
564            ]
565        );
566    }
567
568    fn unix_timestamp_nanos(instant: SystemTime) -> Option<u64> {
569        let duration = instant.duration_since(SystemTime::UNIX_EPOCH).ok()?;
570        Some(duration.as_nanos() as u64)
571    }
572
573    #[tokio::test]
574    #[cfg_attr(not(feature = "host-fs"), ignore)]
575    async fn python_use_case() {
576        let temp = TempDir::new().unwrap();
577        let sub_dir = temp.path().join("path").join("to");
578        std::fs::create_dir_all(&sub_dir).unwrap();
579        std::fs::write(sub_dir.join("file.txt"), b"Hello, World!").unwrap();
580        let mapping = [MountedDirectory::from(MappedDirectory {
581            guest: "/home".to_string(),
582            host: sub_dir,
583        })];
584        let container = wasmer_package::utils::from_bytes(PYTHON).unwrap();
585        let webc_fs = virtual_fs::WebcVolumeFileSystem::mount_all(&container);
586        let mount_fs = MountFileSystem::new();
587        mount_fs.mount(Path::new("/"), Arc::new(webc_fs)).unwrap();
588
589        let root_fs = RootFileSystemBuilder::default().build();
590        let fs = prepare_filesystem(
591            base_root(&root_fs),
592            None,
593            &mapping,
594            Some(&package_mounts(mount_fs)),
595            ExistingMountConflictBehavior::Override,
596        )
597        .unwrap();
598
599        use virtual_fs::FileSystem;
600        assert!(fs.metadata("/home/file.txt".as_ref()).unwrap().is_file());
601        assert!(fs.metadata("lib".as_ref()).unwrap().is_dir());
602        assert!(
603            fs.metadata("lib/python3.6/collections/__init__.py".as_ref())
604                .unwrap()
605                .is_file()
606        );
607        assert!(
608            fs.metadata("lib/python3.6/encodings/__init__.py".as_ref())
609                .unwrap()
610                .is_file()
611        );
612    }
613
614    #[tokio::test]
615    async fn package_mount_paths_remain_writable() {
616        use virtual_fs::FileSystem;
617
618        let container = wasmer_package::utils::from_bytes(PYTHON).unwrap();
619        let pkg_mount = virtual_fs::WebcVolumeFileSystem::mount_all(&container);
620
621        let mount_fs = MountFileSystem::new();
622        mount_fs
623            .mount(Path::new("/python"), Arc::new(pkg_mount))
624            .unwrap();
625
626        let root_fs = RootFileSystemBuilder::default().build();
627        let fs = prepare_filesystem(
628            base_root(&root_fs),
629            None,
630            &[],
631            Some(&package_mounts(mount_fs)),
632            ExistingMountConflictBehavior::Override,
633        )
634        .unwrap();
635
636        fs.create_dir(Path::new("/python/custom")).unwrap();
637        fs.new_open_options()
638            .create(true)
639            .write(true)
640            .open(Path::new("/python/custom/sitecustomize.py"))
641            .unwrap();
642
643        assert!(
644            fs.metadata(Path::new("/python/custom/sitecustomize.py"))
645                .unwrap()
646                .is_file()
647        );
648        assert!(
649            fs.metadata(Path::new("/python/lib/python3.6/collections/__init__.py"))
650                .unwrap()
651                .is_file()
652        );
653    }
654
655    #[tokio::test]
656    async fn package_mount_symlinks_remain_writable() {
657        use virtual_fs::FileSystem;
658
659        let container = wasmer_package::utils::from_bytes(PYTHON).unwrap();
660        let pkg_mount = virtual_fs::WebcVolumeFileSystem::mount_all(&container);
661
662        let mount_fs = MountFileSystem::new();
663        mount_fs
664            .mount(Path::new("/python"), Arc::new(pkg_mount))
665            .unwrap();
666
667        let root_fs = RootFileSystemBuilder::default().build();
668        let fs = prepare_filesystem(
669            base_root(&root_fs),
670            None,
671            &[],
672            Some(&package_mounts(mount_fs)),
673            ExistingMountConflictBehavior::Override,
674        )
675        .unwrap();
676
677        fs.create_symlink(
678            Path::new("lib/python3.6/collections"),
679            Path::new("/python/collections-link"),
680        )
681        .unwrap();
682
683        assert_eq!(
684            fs.readlink(Path::new("/python/collections-link")).unwrap(),
685            Path::new("lib/python3.6/collections")
686        );
687        assert!(
688            fs.symlink_metadata(Path::new("/python/collections-link"))
689                .unwrap()
690                .ft
691                .is_symlink()
692        );
693    }
694
695    #[tokio::test]
696    async fn user_mounts_override_package_mounts_when_configured() {
697        use virtual_fs::FileSystem;
698
699        let user_mount = TmpFileSystem::new();
700        user_mount
701            .new_open_options()
702            .create(true)
703            .write(true)
704            .open(Path::new("/user.txt"))
705            .unwrap();
706
707        let package_mount = TmpFileSystem::new();
708        package_mount
709            .new_open_options()
710            .create(true)
711            .write(true)
712            .open(Path::new("/pkg.txt"))
713            .unwrap();
714
715        let mounted_dirs = [MountedDirectory {
716            guest: "/python".to_string(),
717            fs: Arc::new(user_mount),
718        }];
719
720        let container_mounts = MountFileSystem::new();
721        container_mounts
722            .mount(Path::new("/python"), Arc::new(package_mount))
723            .unwrap();
724
725        let root_fs = RootFileSystemBuilder::default().build();
726        let fs = prepare_filesystem(
727            base_root(&root_fs),
728            None,
729            &mounted_dirs,
730            Some(&package_mounts(container_mounts)),
731            ExistingMountConflictBehavior::Override,
732        )
733        .unwrap();
734
735        assert!(
736            fs.metadata(Path::new("/python/user.txt"))
737                .unwrap()
738                .is_file()
739        );
740        assert_eq!(
741            fs.metadata(Path::new("/python/pkg.txt")),
742            Err(virtual_fs::FsError::EntryNotFound)
743        );
744    }
745
746    #[tokio::test]
747    async fn conflicting_mounts_fail_when_configured() {
748        let user_mount = TmpFileSystem::new();
749        let package_mount = TmpFileSystem::new();
750
751        let mounted_dirs = [MountedDirectory {
752            guest: "/python".to_string(),
753            fs: Arc::new(user_mount),
754        }];
755
756        let container_mounts = MountFileSystem::new();
757        container_mounts
758            .mount(Path::new("/python"), Arc::new(package_mount))
759            .unwrap();
760
761        let root_fs = RootFileSystemBuilder::default().build();
762        let error = prepare_filesystem(
763            base_root(&root_fs),
764            None,
765            &mounted_dirs,
766            Some(&package_mounts(container_mounts)),
767            ExistingMountConflictBehavior::Fail,
768        )
769        .unwrap_err();
770
771        assert!(
772            error
773                .to_string()
774                .contains("Unable to merge container mount \"/python\""),
775            "{error:#}"
776        );
777    }
778
779    #[tokio::test]
780    async fn root_mounts_are_composed_even_in_fail_mode() {
781        use virtual_fs::FileSystem;
782
783        let root_mount = TmpFileSystem::new();
784        root_mount
785            .new_open_options()
786            .create(true)
787            .write(true)
788            .open(Path::new("/user.txt"))
789            .unwrap();
790
791        let mounted_dirs = [MountedDirectory {
792            guest: "/".to_string(),
793            fs: Arc::new(root_mount),
794        }];
795
796        let container_mounts = MountFileSystem::new();
797        let container_root = TmpFileSystem::new();
798        container_root
799            .new_open_options()
800            .create(true)
801            .write(true)
802            .open(Path::new("/pkg.txt"))
803            .unwrap();
804        container_mounts
805            .mount(Path::new("/"), Arc::new(container_root))
806            .unwrap();
807
808        let root_fs = RootFileSystemBuilder::default().build();
809        let fs = prepare_filesystem(
810            base_root(&root_fs),
811            None,
812            &mounted_dirs,
813            Some(&package_mounts(container_mounts)),
814            ExistingMountConflictBehavior::Fail,
815        )
816        .unwrap();
817
818        assert!(fs.metadata(Path::new("/user.txt")).unwrap().is_file());
819        assert!(fs.metadata(Path::new("/pkg.txt")).unwrap().is_file());
820    }
821
822    #[tokio::test]
823    async fn multiple_root_mounts_are_composed() {
824        use virtual_fs::FileSystem;
825
826        let first_root = TmpFileSystem::new();
827        first_root
828            .new_open_options()
829            .create(true)
830            .write(true)
831            .open(Path::new("/first.txt"))
832            .unwrap();
833
834        let second_root = TmpFileSystem::new();
835        second_root
836            .new_open_options()
837            .create(true)
838            .write(true)
839            .open(Path::new("/second.txt"))
840            .unwrap();
841
842        let mounted_dirs = [
843            MountedDirectory {
844                guest: "/".to_string(),
845                fs: Arc::new(first_root),
846            },
847            MountedDirectory {
848                guest: "/".to_string(),
849                fs: Arc::new(second_root),
850            },
851        ];
852
853        let root_fs = RootFileSystemBuilder::default().build();
854        let fs = prepare_filesystem(
855            base_root(&root_fs),
856            None,
857            &mounted_dirs,
858            None,
859            ExistingMountConflictBehavior::Fail,
860        )
861        .unwrap();
862
863        assert!(fs.metadata(Path::new("/first.txt")).unwrap().is_file());
864        assert!(fs.metadata(Path::new("/second.txt")).unwrap().is_file());
865    }
866
867    #[tokio::test]
868    async fn prepared_filesystem_preserves_root_memory_limiter() {
869        let limiter: virtual_fs::limiter::DynFsMemoryLimiter = Arc::new(CountingLimiter::new(1));
870
871        let package_mount = TmpFileSystem::new();
872        let container_mounts = MountFileSystem::new();
873        container_mounts
874            .mount(Path::new("/python"), Arc::new(package_mount))
875            .unwrap();
876
877        let root_fs = RootFileSystemBuilder::default().build();
878        let fs = prepare_filesystem(
879            base_root(&root_fs),
880            Some(&limiter),
881            &[],
882            Some(&package_mounts(container_mounts)),
883            ExistingMountConflictBehavior::Override,
884        )
885        .unwrap();
886
887        assert!(fs.memory_limiter().is_some());
888    }
889
890    #[test]
891    fn invalid_guest_mount_paths_are_rejected() {
892        let error = normalized_mount_path("../../python").unwrap_err();
893        assert!(
894            error
895                .to_string()
896                .contains("parent traversal escapes the virtual root"),
897            "{error:#}"
898        );
899    }
900
901    #[tokio::test]
902    #[cfg_attr(not(feature = "host-fs"), ignore)]
903    async fn convert_mapped_directory_to_mounted_directory() {
904        let temp = TempDir::new().unwrap();
905        let dir = MappedDirectory {
906            guest: "/mnt/dir".to_string(),
907            host: temp.path().to_path_buf(),
908        };
909        let contents = "Hello, World!";
910        let file_txt = temp.path().join("file.txt");
911        std::fs::write(&file_txt, contents).unwrap();
912        let metadata = std::fs::metadata(&file_txt).unwrap();
913
914        let got = MountedDirectory::from(dir);
915
916        let directory_contents: Vec<_> = got
917            .fs
918            .read_dir("/".as_ref())
919            .unwrap()
920            .map(|entry| entry.unwrap())
921            .collect();
922        assert_eq!(
923            directory_contents,
924            vec![DirEntry {
925                path: PathBuf::from("/file.txt"),
926                metadata: Ok(Metadata {
927                    ft: FileType::new_file(),
928                    // Note: Some timestamps aren't available on MUSL and will
929                    // default to zero.
930                    accessed: metadata
931                        .accessed()
932                        .ok()
933                        .and_then(unix_timestamp_nanos)
934                        .unwrap_or(0),
935                    created: metadata
936                        .created()
937                        .ok()
938                        .and_then(unix_timestamp_nanos)
939                        .unwrap_or(0),
940                    modified: metadata
941                        .modified()
942                        .ok()
943                        .and_then(unix_timestamp_nanos)
944                        .unwrap_or(0),
945                    len: contents.len() as u64,
946                })
947            }]
948        );
949    }
950}