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
31fn 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
38fn 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
51const MAX_PARALLEL_DOWNLOADS: usize = 32;
53
54#[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#[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 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 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
257fn 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 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 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
346fn 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
369fn 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
427fn 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 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
480fn 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 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 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 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 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 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}