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
30fn 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
37fn 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
50const MAX_PARALLEL_DOWNLOADS: usize = 32;
52
53#[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#[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 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 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
256fn 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 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 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
345fn 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
382fn 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
440fn 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 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
501fn 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 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 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 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}