wasmer_wasix/runtime/package_loader/
load_package_tree.rs

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