wasmer_wasix/runtime/package_loader/
load_package_tree.rs

1use std::{
2    collections::{BTreeMap, HashMap, HashSet},
3    fmt::Debug,
4    path::{Path, PathBuf},
5    sync::Arc,
6};
7
8use anyhow::{Context, Error};
9use futures::{StreamExt, TryStreamExt, future::BoxFuture};
10use once_cell::sync::OnceCell;
11use petgraph::visit::EdgeRef;
12use virtual_fs::{FileSystem, UnionFileSystem, WebcVolumeFileSystem};
13use wasmer_config::package::PackageId;
14use wasmer_package::utils::wasm_annotations_to_features;
15use webc::metadata::annotations::Atom as AtomAnnotation;
16use webc::{Container, Volume};
17
18use crate::{
19    bin_factory::{BinaryPackage, BinaryPackageCommand},
20    runtime::{
21        package_loader::PackageLoader,
22        resolver::{
23            DependencyGraph, ItemLocation, PackageSummary, Resolution, ResolvedFileSystemMapping,
24            ResolvedPackage,
25        },
26    },
27};
28
29use super::to_module_hash;
30
31/// Convert WebAssembly feature annotations to a Features object
32fn wasm_annotation_to_features(
33    wasm_annotation: &webc::metadata::annotations::Wasm,
34) -> Option<wasmer_types::Features> {
35    Some(wasm_annotations_to_features(&wasm_annotation.features))
36}
37
38/// Extract WebAssembly features from atom metadata if available
39fn extract_features_from_atom_metadata(
40    atom_metadata: &webc::metadata::Atom,
41) -> Option<wasmer_types::Features> {
42    if let Ok(Some(wasm_annotation)) = atom_metadata
43        .annotation::<webc::metadata::annotations::Wasm>(webc::metadata::annotations::Wasm::KEY)
44    {
45        wasm_annotation_to_features(&wasm_annotation)
46    } else {
47        None
48    }
49}
50
51/// The maximum number of packages that will be loaded in parallel.
52const MAX_PARALLEL_DOWNLOADS: usize = 32;
53
54/// Given a fully resolved package, load it into memory for execution.
55#[tracing::instrument(level = "debug", skip_all)]
56pub async fn load_package_tree(
57    root: &Container,
58    loader: &dyn PackageLoader,
59    resolution: &Resolution,
60    root_is_local_dir: bool,
61) -> Result<BinaryPackage, Error> {
62    let mut containers = fetch_dependencies(loader, &resolution.package, &resolution.graph).await?;
63    containers.insert(resolution.package.root_package.clone(), root.clone());
64    let package_ids = containers.keys().cloned().collect();
65    let fs_opt = filesystem(&containers, &resolution.package, root_is_local_dir)?;
66
67    let root = &resolution.package.root_package;
68    let commands = commands(&resolution.package.commands, &containers, resolution)?;
69
70    let file_system_memory_footprint = if let Some(fs) = &fs_opt {
71        count_file_system(fs, Path::new("/"))
72    } else {
73        0
74    };
75
76    let loaded = BinaryPackage {
77        id: root.clone(),
78        package_ids,
79        when_cached: crate::syscalls::platform_clock_time_get(
80            wasmer_wasix_types::wasi::Snapshot0Clockid::Monotonic,
81            1_000_000,
82        )
83        .ok()
84        .map(|ts| ts as u128),
85        hash: OnceCell::new(),
86        entrypoint_cmd: resolution.package.entrypoint.clone(),
87        webc_fs: fs_opt.map(Arc::new),
88        commands,
89        uses: Vec::new(),
90        file_system_memory_footprint,
91
92        additional_host_mapped_directories: vec![],
93    };
94
95    Ok(loaded)
96}
97
98fn commands(
99    commands: &BTreeMap<String, ItemLocation>,
100    containers: &HashMap<PackageId, Container>,
101    resolution: &Resolution,
102) -> Result<Vec<BinaryPackageCommand>, Error> {
103    let mut pkg_commands = Vec::new();
104
105    for (
106        name,
107        ItemLocation {
108            name: original_name,
109            package,
110        },
111    ) in commands
112    {
113        let webc = &containers[package];
114        let manifest = webc.manifest();
115        let command_metadata = &manifest.commands[original_name];
116
117        if let Some(cmd) =
118            load_binary_command(package, name, command_metadata, containers, resolution)?
119        {
120            pkg_commands.push(cmd);
121        }
122    }
123
124    Ok(pkg_commands)
125}
126
127/// Given a [`webc::metadata::Command`], figure out which atom it uses and load
128/// that atom into a [`BinaryPackageCommand`].
129#[tracing::instrument(skip_all, fields(%package_id, %command_name))]
130fn load_binary_command(
131    package_id: &PackageId,
132    command_name: &str,
133    cmd: &webc::metadata::Command,
134    containers: &HashMap<PackageId, Container>,
135    resolution: &Resolution,
136) -> Result<Option<BinaryPackageCommand>, anyhow::Error> {
137    let AtomAnnotation {
138        name: atom_name,
139        dependency,
140        ..
141    } = match atom_name_for_command(command_name, cmd)? {
142        Some(name) => name,
143        None => {
144            tracing::warn!(
145                cmd.name=command_name,
146                cmd.runner=%cmd.runner,
147                "Skipping unsupported command",
148            );
149            return Ok(None);
150        }
151    };
152
153    let package = &containers[package_id];
154
155    let (webc, resolved_package_id) = match dependency {
156        Some(dep) => {
157            let ix = resolution
158                .graph
159                .packages()
160                .get(package_id)
161                .copied()
162                .unwrap();
163            let graph = resolution.graph.graph();
164            let edge_reference = graph
165                .edges_directed(ix, petgraph::Direction::Outgoing)
166                .find(|edge| edge.weight().alias == dep)
167                .with_context(|| format!("Unable to find the \"{dep}\" dependency for the \"{command_name}\" command in \"{package_id}\""))?;
168
169            let other_package = graph.node_weight(edge_reference.target()).unwrap();
170            let id = &other_package.id;
171
172            tracing::debug!(
173                dependency=%dep,
174                resolved_package_id=%id,
175                "command atom resolution: resolved dependency",
176            );
177            (&containers[id], id)
178        }
179        None => (package, package_id),
180    };
181
182    let atom = webc.get_atom(&atom_name);
183
184    if atom.is_none() && cmd.annotations.is_empty() {
185        tracing::info!("applying legacy atom hack");
186        return legacy_atom_hack(webc, package_id, command_name, cmd);
187    }
188
189    let hash = to_module_hash(webc.manifest().atom_signature(&atom_name)?);
190
191    let atom = atom.with_context(|| {
192
193        let available_atoms = webc.atoms().keys().map(|x| x.as_str()).collect::<Vec<_>>().join(",");
194
195        tracing::warn!(
196            %atom_name,
197            %resolved_package_id,
198            %available_atoms,
199            "invalid command: could not find atom in package",
200        );
201
202        format!(
203            "The '{command_name}' command uses the '{atom_name}' atom, but it isn't present in the package: {resolved_package_id})"
204        )
205    })?;
206
207    // Get WebAssembly features from manifest atom annotations
208    let features = if let Some(atom_metadata) = webc.manifest().atoms.get(&atom_name) {
209        extract_features_from_atom_metadata(atom_metadata)
210    } else {
211        None
212    };
213
214    let cmd = BinaryPackageCommand::new(
215        command_name.to_string(),
216        cmd.clone(),
217        atom,
218        hash,
219        features,
220        package_id.clone(),
221        resolved_package_id.clone(),
222    );
223
224    Ok(Some(cmd))
225}
226
227fn atom_name_for_command(
228    command_name: &str,
229    cmd: &webc::metadata::Command,
230) -> Result<Option<AtomAnnotation>, anyhow::Error> {
231    use webc::metadata::annotations::{WASI_RUNNER_URI, WCGI_RUNNER_URI};
232
233    if let Some(atom) = cmd
234        .atom()
235        .context("Unable to deserialize atom annotations")?
236    {
237        return Ok(Some(atom));
238    }
239
240    if [WASI_RUNNER_URI, WCGI_RUNNER_URI]
241        .iter()
242        .any(|uri| cmd.runner.starts_with(uri))
243    {
244        // Note: We use the command name as the atom name as a special case
245        // for known runner types because sometimes people will construct
246        // a manifest by hand instead of using wapm2pirita.
247        tracing::debug!(
248            command = command_name,
249            "No annotations specifying the atom name found. Falling back to the command name"
250        );
251        return Ok(Some(AtomAnnotation::new(command_name, None)));
252    }
253
254    Ok(None)
255}
256
257/// HACK: Some older packages like `sharrattj/bash` and `sharrattj/coreutils`
258/// contain commands with no annotations. When this happens, you can just assume
259/// it wants to use the first atom in the WEBC file.
260///
261/// That works because most of these packages only have a single atom (e.g. in
262/// `sharrattj/coreutils` there are commands for `ls`, `pwd`, and so on, but
263/// under the hood they all use the `coreutils` atom).
264///
265/// See <https://github.com/wasmerio/wasmer/commit/258903140680716da1431d92bced67d486865aeb>
266/// for more.
267fn legacy_atom_hack(
268    webc: &Container,
269    package_id: &PackageId,
270    command_name: &str,
271    metadata: &webc::metadata::Command,
272) -> Result<Option<BinaryPackageCommand>, anyhow::Error> {
273    let (name, atom) = webc
274        .atoms()
275        .into_iter()
276        .next()
277        .ok_or_else(|| anyhow::Error::msg("container does not have any atom"))?;
278
279    tracing::debug!(
280        command_name,
281        atom.name = name.as_str(),
282        atom.len = atom.len(),
283        "(hack) The command metadata is malformed. Falling back to the first atom in the WEBC file",
284    );
285
286    let hash = to_module_hash(webc.manifest().atom_signature(&name)?);
287
288    // Get WebAssembly features from manifest atom annotations
289    let features = if let Some(atom_metadata) = webc.manifest().atoms.get(&name) {
290        extract_features_from_atom_metadata(atom_metadata)
291    } else {
292        None
293    };
294
295    Ok(Some(BinaryPackageCommand::new(
296        command_name.to_string(),
297        metadata.clone(),
298        atom,
299        hash,
300        features,
301        package_id.clone(),
302        package_id.clone(),
303    )))
304}
305
306async fn fetch_dependencies(
307    loader: &dyn PackageLoader,
308    pkg: &ResolvedPackage,
309    graph: &DependencyGraph,
310) -> Result<HashMap<PackageId, Container>, Error> {
311    let mut packages = HashSet::new();
312
313    for loc in pkg.commands.values() {
314        packages.insert(loc.package.clone());
315    }
316
317    for mapping in &pkg.filesystem {
318        packages.insert(mapping.package.clone());
319    }
320
321    // We don't need to download the root package
322    packages.remove(&pkg.root_package);
323
324    let packages = packages.into_iter().filter_map(|id| {
325        let crate::runtime::resolver::Node { pkg, dist, .. } = &graph[&id];
326        let summary = PackageSummary {
327            pkg: pkg.clone(),
328            dist: dist.clone()?,
329        };
330        Some((id, summary))
331    });
332    let packages: HashMap<PackageId, Container> = futures::stream::iter(packages)
333        .map(|(id, s)| async move {
334            match loader.load(&s).await {
335                Ok(webc) => Ok((id, webc)),
336                Err(e) => Err(e),
337            }
338        })
339        .buffer_unordered(MAX_PARALLEL_DOWNLOADS)
340        .try_collect()
341        .await?;
342
343    Ok(packages)
344}
345
346/// How many bytes worth of files does a directory contain?
347fn count_file_system(fs: &dyn FileSystem, path: &Path) -> u64 {
348    let mut total = 0;
349
350    let dir = match fs.read_dir(path) {
351        Ok(d) => d,
352        Err(_err) => {
353            return 0;
354        }
355    };
356
357    for entry in dir.flatten() {
358        if let Ok(meta) = entry.metadata() {
359            total += meta.len();
360            if meta.is_dir() {
361                total += count_file_system(fs, entry.path.as_path());
362            }
363        }
364    }
365
366    total
367}
368
369/// Given a set of [`ResolvedFileSystemMapping`]s and the [`Container`] for each
370/// package in a dependency tree, construct the resulting filesystem.
371///
372/// Returns `Ok(None)` if no filesystem mappings were specified.
373fn filesystem(
374    packages: &HashMap<PackageId, Container>,
375    pkg: &ResolvedPackage,
376    root_is_local_dir: bool,
377) -> Result<Option<UnionFileSystem>, Error> {
378    if pkg.filesystem.is_empty() {
379        return Ok(None);
380    }
381
382    let mut found_v2 = None;
383    let mut found_v3 = None;
384
385    for ResolvedFileSystemMapping { package, .. } in &pkg.filesystem {
386        let container = packages.get(package).with_context(|| {
387            format!(
388                "\"{}\" wants to use the \"{}\" package, but it isn't in the dependency tree",
389                pkg.root_package, package,
390            )
391        })?;
392
393        match container.version() {
394            webc::Version::V1 => {
395                anyhow::bail!(
396                    "the package '{package}' is a webc v1 package, but webc v1 support was removed"
397                );
398            }
399            webc::Version::V2 => {
400                if found_v2.is_none() {
401                    found_v2 = Some(package.clone());
402                }
403            }
404            webc::Version::V3 => {
405                if found_v3.is_none() {
406                    found_v3 = Some(package.clone());
407                }
408            }
409            other => {
410                anyhow::bail!("the package '{package}' has an unknown webc version: {other}");
411            }
412        }
413    }
414
415    match (found_v2, found_v3) {
416        (None, Some(_)) => filesystem_v3(packages, pkg, root_is_local_dir).map(Some),
417        (Some(_), None) => filesystem_v2(packages, pkg, root_is_local_dir).map(Some),
418        (Some(v2), Some(v3)) => {
419            anyhow::bail!(
420                "Mix of webc v2 and v3 in the same dependency tree is not supported; v2: {v2}, v3: {v3}"
421            )
422        }
423        (None, None) => anyhow::bail!("Internal error: no packages found in tree"),
424    }
425}
426
427/// Build the filesystem for webc v3 packages.
428fn filesystem_v3(
429    packages: &HashMap<PackageId, Container>,
430    pkg: &ResolvedPackage,
431    root_is_local_dir: bool,
432) -> Result<UnionFileSystem, Error> {
433    let mut volumes: HashMap<&PackageId, BTreeMap<String, Volume>> = HashMap::new();
434
435    let union_fs = UnionFileSystem::new();
436
437    for ResolvedFileSystemMapping {
438        mount_path,
439        volume_name,
440        package,
441        ..
442    } in &pkg.filesystem
443    {
444        if *package == pkg.root_package && root_is_local_dir {
445            continue;
446        }
447
448        if mount_path.as_path() == Path::new("/") {
449            tracing::warn!(
450                "The \"{package}\" package wants to mount a volume at \"/\", which breaks WASIX modules' filesystems",
451            );
452        }
453
454        // Note: We want to reuse existing Volume instances if we can. That way
455        // we can keep the memory usage down. A webc::compat::Volume is
456        // reference-counted, anyway.
457        // looks like we need to insert it
458        let container = packages.get(package).with_context(|| {
459            format!(
460                "\"{}\" wants to use the \"{}\" package, but it isn't in the dependency tree",
461                pkg.root_package, package,
462            )
463        })?;
464        let container_volumes = match volumes.entry(package) {
465            std::collections::hash_map::Entry::Occupied(entry) => &*entry.into_mut(),
466            std::collections::hash_map::Entry::Vacant(entry) => &*entry.insert(container.volumes()),
467        };
468
469        let volume = container_volumes.get(volume_name).with_context(|| {
470            format!("The \"{package}\" package doesn't have a \"{volume_name}\" volume")
471        })?;
472
473        let webc_vol = WebcVolumeFileSystem::new(volume.clone());
474        union_fs.mount(volume_name.clone(), mount_path, Box::new(webc_vol))?;
475    }
476
477    Ok(union_fs)
478}
479
480/// Build the filesystem for webc v2 packages.
481///
482// # Note to future readers
483//
484// Sooo... this code is a bit convoluted because we're constrained by the
485// filesystem implementations we've got available.
486//
487// Ideally, we would create a WebcVolumeFileSystem for each volume we're
488// using, then we'd have a single "union" filesystem which lets you mount
489// filesystem objects under various paths and can deal with conflicts.
490//
491// The OverlayFileSystem lets us make files from multiple filesystem
492// implementations available at the same time, however all of the
493// filesystems will be mounted at "/", when the user wants to mount volumes
494// at arbitrary locations.
495//
496// The TmpFileSystem *does* allow mounting at non-root paths, however it can't
497// handle nested paths (e.g. mounting to "/lib" and "/lib/python3.10" - see
498// <https://github.com/wasmerio/wasmer/issues/3678> for more) and you aren't
499// allowed to mount to "/" because it's a special directory that already
500// exists.
501//
502// As a result, we'll duct-tape things together and hope for the best 🤞
503fn filesystem_v2(
504    packages: &HashMap<PackageId, Container>,
505    pkg: &ResolvedPackage,
506    root_is_local_dir: bool,
507) -> Result<UnionFileSystem, Error> {
508    let mut volumes: HashMap<&PackageId, BTreeMap<String, Volume>> = HashMap::new();
509
510    let union_fs = UnionFileSystem::new();
511
512    for ResolvedFileSystemMapping {
513        mount_path,
514        volume_name,
515        package,
516        original_path,
517    } in &pkg.filesystem
518    {
519        if *package == pkg.root_package && root_is_local_dir {
520            continue;
521        }
522
523        if mount_path.as_path() == Path::new("/") {
524            tracing::warn!(
525                "The \"{package}\" package wants to mount a volume at \"/\", which breaks WASIX modules' filesystems",
526            );
527        }
528
529        // Note: We want to reuse existing Volume instances if we can. That way
530        // we can keep the memory usage down. A webc::compat::Volume is
531        // reference-counted, anyway.
532        let container_volumes = match volumes.entry(package) {
533            std::collections::hash_map::Entry::Occupied(entry) => &*entry.into_mut(),
534            std::collections::hash_map::Entry::Vacant(entry) => {
535                // looks like we need to insert it
536                let container = packages.get(package)
537                    .with_context(|| format!(
538                        "\"{}\" wants to use the \"{}\" package, but it isn't in the dependency tree",
539                        pkg.root_package,
540                        package,
541                    ))?;
542                &*entry.insert(container.volumes())
543            }
544        };
545
546        let volume = container_volumes.get(volume_name).with_context(|| {
547            format!("The \"{package}\" package doesn't have a \"{volume_name}\" volume")
548        })?;
549
550        // UnionFileSystem strips the mount point before forwarding paths to the
551        // mounted filesystem. That means paths are already relative to the
552        // mount root and shouldn't be stripped by mount_path.
553        let fs = if let Some(original) = original_path {
554            let original = PathBuf::from(original);
555
556            MappedPathFileSystem::new(
557                WebcVolumeFileSystem::new(volume.clone()),
558                Box::new(move |path: &Path| Ok(original.join(strip_root_prefix(path))))
559                    as DynPathMapper,
560            )
561        } else {
562            MappedPathFileSystem::new(
563                WebcVolumeFileSystem::new(volume.clone()),
564                Box::new(move |path: &Path| Ok(strip_root_prefix(path))) as DynPathMapper,
565            )
566        };
567
568        union_fs.mount(volume_name.clone(), mount_path, Box::new(fs))?;
569    }
570
571    Ok(union_fs)
572}
573
574fn strip_root_prefix(path: &Path) -> PathBuf {
575    path.strip_prefix("/").unwrap_or(path).to_owned()
576}
577
578type DynPathMapper = Box<dyn Fn(&Path) -> Result<PathBuf, virtual_fs::FsError> + Send + Sync>;
579
580struct MappedPathFileSystem<F, M> {
581    inner: F,
582    map: M,
583}
584
585impl<F, M> MappedPathFileSystem<F, M>
586where
587    M: Fn(&Path) -> Result<PathBuf, virtual_fs::FsError> + Send + Sync + 'static,
588{
589    fn new(inner: F, map: M) -> Self {
590        MappedPathFileSystem { inner, map }
591    }
592
593    fn path(&self, path: &Path) -> Result<PathBuf, virtual_fs::FsError> {
594        let path = (self.map)(path)?;
595
596        // Don't forget to make the path absolute again.
597        Ok(Path::new("/").join(path))
598    }
599}
600
601impl<M, F> FileSystem for MappedPathFileSystem<F, M>
602where
603    F: FileSystem,
604    M: Fn(&Path) -> Result<PathBuf, virtual_fs::FsError> + Send + Sync + 'static,
605{
606    fn readlink(&self, path: &Path) -> virtual_fs::Result<PathBuf> {
607        let path = self.path(path)?;
608        self.inner.readlink(&path)
609    }
610
611    fn read_dir(&self, path: &Path) -> virtual_fs::Result<virtual_fs::ReadDir> {
612        let path = self.path(path)?;
613        self.inner.read_dir(&path)
614    }
615
616    fn create_dir(&self, path: &Path) -> virtual_fs::Result<()> {
617        let path = self.path(path)?;
618        self.inner.create_dir(&path)
619    }
620
621    fn remove_dir(&self, path: &Path) -> virtual_fs::Result<()> {
622        let path = self.path(path)?;
623        self.inner.remove_dir(&path)
624    }
625
626    fn rename<'a>(&'a self, from: &Path, to: &Path) -> BoxFuture<'a, virtual_fs::Result<()>> {
627        let from = from.to_owned();
628        let to = to.to_owned();
629        Box::pin(async move {
630            let from = self.path(&from)?;
631            let to = self.path(&to)?;
632            self.inner.rename(&from, &to).await
633        })
634    }
635
636    fn metadata(&self, path: &Path) -> virtual_fs::Result<virtual_fs::Metadata> {
637        let path = self.path(path)?;
638        self.inner.metadata(&path)
639    }
640
641    fn symlink_metadata(&self, path: &Path) -> virtual_fs::Result<virtual_fs::Metadata> {
642        let path = self.path(path)?;
643        self.inner.symlink_metadata(&path)
644    }
645
646    fn remove_file(&self, path: &Path) -> virtual_fs::Result<()> {
647        let path = self.path(path)?;
648        self.inner.remove_file(&path)
649    }
650
651    fn new_open_options(&self) -> virtual_fs::OpenOptions<'_> {
652        virtual_fs::OpenOptions::new(self)
653    }
654
655    fn mount(
656        &self,
657        name: String,
658        path: &Path,
659        fs: Box<dyn FileSystem + Send + Sync>,
660    ) -> virtual_fs::Result<()> {
661        let path = self.path(path)?;
662        self.inner.mount(name, path.as_path(), fs)
663    }
664}
665
666impl<F, M> virtual_fs::FileOpener for MappedPathFileSystem<F, M>
667where
668    F: FileSystem,
669    M: Fn(&Path) -> Result<PathBuf, virtual_fs::FsError> + Send + Sync + 'static,
670{
671    fn open(
672        &self,
673        path: &Path,
674        conf: &virtual_fs::OpenOptionsConfig,
675    ) -> virtual_fs::Result<Box<dyn virtual_fs::VirtualFile + Send + Sync + 'static>> {
676        let path = self.path(path)?;
677        self.inner
678            .new_open_options()
679            .options(conf.clone())
680            .open(path)
681    }
682}
683
684impl<F, M> Debug for MappedPathFileSystem<F, M>
685where
686    F: Debug,
687{
688    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
689        f.debug_struct("MappedPathFileSystem")
690            .field("inner", &self.inner)
691            .field("map", &std::any::type_name::<M>())
692            .finish()
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use std::{
699        collections::{BTreeMap, HashMap},
700        path::{Path, PathBuf},
701    };
702
703    use ciborium::value::Value;
704    use virtual_fs::FileSystem;
705    use wasmer_config::package::PackageId;
706    use webc::{
707        Container,
708        indexmap::IndexMap,
709        metadata::{
710            Manifest,
711            annotations::{FileSystemMapping, FileSystemMappings},
712        },
713        v2::{
714            SignatureAlgorithm,
715            read::OwnedReader,
716            write::{DirEntry, Directory, FileEntry, Writer},
717        },
718    };
719
720    use super::{ResolvedFileSystemMapping, ResolvedPackage, filesystem_v2};
721
722    #[test]
723    fn v2_filesystem_mapping_resolves_mount_paths() {
724        // Regression test: v2 fs mounts are already relative to the mount root,
725        // so stripping the mount path again breaks lookups like /public.
726        let mut manifest = Manifest::default();
727        let fs = FileSystemMappings(vec![FileSystemMapping {
728            from: None,
729            volume_name: "atom".to_string(),
730            host_path: Some("/public".to_string()),
731            mount_path: "/public".to_string(),
732        }]);
733        let mut package = IndexMap::new();
734        package.insert(
735            FileSystemMappings::KEY.to_string(),
736            Value::serialized(&fs).unwrap(),
737        );
738        manifest.package = package;
739
740        let mut public_children = BTreeMap::new();
741        public_children.insert(
742            "index.html".parse().unwrap(),
743            DirEntry::File(FileEntry::from(b"ok".as_slice())),
744        );
745        let public_dir = Directory {
746            children: public_children,
747        };
748        let mut root_children = BTreeMap::new();
749        root_children.insert("public".parse().unwrap(), DirEntry::Dir(public_dir));
750        let atom_dir = Directory {
751            children: root_children,
752        };
753
754        let writer = Writer::default().write_manifest(&manifest).unwrap();
755        let writer = writer.write_atoms(BTreeMap::new()).unwrap();
756        let writer = writer.with_volume("atom", atom_dir).unwrap();
757        let bytes = writer.finish(SignatureAlgorithm::None).unwrap();
758
759        let reader = OwnedReader::parse(bytes).unwrap();
760        let container = Container::from(reader);
761
762        let pkg_id = PackageId::new_named("ns/pkg", "0.1.0".parse().unwrap());
763        let mut packages = HashMap::new();
764        packages.insert(pkg_id.clone(), container);
765
766        let pkg = ResolvedPackage {
767            root_package: pkg_id.clone(),
768            commands: BTreeMap::new(),
769            entrypoint: None,
770            filesystem: vec![ResolvedFileSystemMapping {
771                mount_path: PathBuf::from("/public"),
772                volume_name: "atom".to_string(),
773                original_path: Some("/public".to_string()),
774                package: pkg_id,
775            }],
776        };
777
778        let union_fs = filesystem_v2(&packages, &pkg, false).unwrap();
779        assert!(union_fs.metadata(Path::new("/public")).unwrap().is_dir());
780        assert!(
781            union_fs
782                .metadata(Path::new("/public/index.html"))
783                .unwrap()
784                .is_file()
785        );
786    }
787}