1use anyhow::Context;
2use indexmap::IndexMap;
3use semver::Version;
4use spin_app::locked::{self, InheritConfiguration, LockedComponent, LockedComponentDependency};
5use spin_common::{ui::quoted_path, url::parse_file_url};
6use spin_serde::{DependencyName, KebabId};
7use std::collections::BTreeMap;
8use thiserror::Error;
9use wac_graph::types::{Package, SubtypeChecker, WorldId};
10use wac_graph::{CompositionGraph, NodeId};
11
12pub async fn compose<L: ComponentSourceLoader>(
31 loader: &L,
32 component: &LockedComponent,
33) -> Result<Vec<u8>, ComposeError> {
34 Composer::new(loader).compose(component).await
35}
36
37#[async_trait::async_trait]
39pub trait ComponentSourceLoader {
40 async fn load_component_source(
41 &self,
42 source: &locked::LockedComponentSource,
43 ) -> anyhow::Result<Vec<u8>>;
44}
45
46pub struct ComponentSourceLoaderFs;
48
49#[async_trait::async_trait]
50impl ComponentSourceLoader for ComponentSourceLoaderFs {
51 async fn load_component_source(
52 &self,
53 source: &locked::LockedComponentSource,
54 ) -> anyhow::Result<Vec<u8>> {
55 let source = source
56 .content
57 .source
58 .as_ref()
59 .context("LockedComponentSource missing source field")?;
60
61 let path = parse_file_url(source)?;
62
63 let bytes: Vec<u8> = tokio::fs::read(&path).await.with_context(|| {
64 format!(
65 "failed to read component source from disk at path {}",
66 quoted_path(&path)
67 )
68 })?;
69
70 let component = spin_componentize::componentize_if_necessary(&bytes)?;
71
72 Ok(component.into())
73 }
74}
75
76#[derive(Debug, Error)]
78pub enum ComposeError {
79 #[error(
81 "dependency '{dependency_name}' doesn't match any imports of component '{component_id}'"
82 )]
83 UnmatchedDependencyName {
84 component_id: String,
85 dependency_name: DependencyName,
86 },
87 #[error("component '{component_id}' has dependency conflicts: {}", format_conflicts(.conflicts))]
89 DependencyConflicts {
90 component_id: String,
91 conflicts: Vec<(String, Vec<DependencyName>)>,
92 },
93 #[error("dependency '{dependency_name}' doesn't export '{export_name}' to satisfy import '{import_name}'")]
95 MissingExport {
96 dependency_name: DependencyName,
97 export_name: String,
98 import_name: String,
99 },
100 #[error("an error occurred when preparing dependencies")]
102 PrepareError(#[source] anyhow::Error),
103 #[error("failed to encode composition graph: {0}")]
105 EncodeError(#[source] anyhow::Error),
106}
107
108fn format_conflicts(conflicts: &[(String, Vec<DependencyName>)]) -> String {
109 conflicts
110 .iter()
111 .map(|(import_name, dependency_names)| {
112 format!(
113 "import '{}' satisfied by dependencies: '{}'",
114 import_name,
115 dependency_names
116 .iter()
117 .map(|name| name.to_string())
118 .collect::<Vec<_>>()
119 .join(", ")
120 )
121 })
122 .collect::<Vec<_>>()
123 .join("; ")
124}
125
126struct Composer<'a, L> {
127 graph: CompositionGraph,
128 loader: &'a L,
129}
130
131impl<'a, L: ComponentSourceLoader> Composer<'a, L> {
132 async fn compose(mut self, component: &LockedComponent) -> Result<Vec<u8>, ComposeError> {
133 let source = self
134 .loader
135 .load_component_source(&component.source)
136 .await
137 .map_err(ComposeError::PrepareError)?;
138
139 if component.dependencies.is_empty() {
140 return Ok(source);
141 }
142
143 let (world_id, instantiation_id) = self
144 .register_package(&component.id, None, source)
145 .map_err(ComposeError::PrepareError)?;
146
147 let prepared = self.prepare_dependencies(world_id, component).await?;
148
149 let arguments = self
150 .build_instantiation_arguments(world_id, prepared)
151 .await?;
152
153 for (argument_name, argument) in arguments {
154 self.graph
155 .set_instantiation_argument(instantiation_id, &argument_name, argument)
156 .map_err(|e| ComposeError::PrepareError(e.into()))?;
157 }
158
159 self.export_dependents_exports(world_id, instantiation_id)
160 .map_err(ComposeError::PrepareError)?;
161
162 self.graph
163 .encode(Default::default())
164 .map_err(|e| ComposeError::EncodeError(e.into()))
165 }
166
167 fn new(loader: &'a L) -> Self {
168 Self {
169 graph: CompositionGraph::new(),
170 loader,
171 }
172 }
173
174 async fn prepare_dependencies(
181 &mut self,
182 world_id: WorldId,
183 component: &LockedComponent,
184 ) -> Result<IndexMap<String, DependencyInfo>, ComposeError> {
185 let imports = self.graph.types()[world_id].imports.clone();
186
187 let import_keys = imports.keys().cloned().collect::<Vec<_>>();
188
189 let mut mappings: BTreeMap<String, Vec<DependencyInfo>> = BTreeMap::new();
190
191 for (dependency_name, dependency) in &component.dependencies {
192 let mut matched = Vec::new();
193
194 for import_name in &import_keys {
195 if matches_import(dependency_name, import_name)
196 .map_err(ComposeError::PrepareError)?
197 {
198 matched.push(import_name.clone());
199 }
200 }
201
202 if matched.is_empty() {
203 return Err(ComposeError::UnmatchedDependencyName {
204 component_id: component.id.clone(),
205 dependency_name: dependency_name.clone(),
206 });
207 }
208
209 let info = self
210 .register_dependency(dependency_name.clone(), dependency)
211 .await
212 .map_err(ComposeError::PrepareError)?;
213
214 for import_name in matched {
216 mappings
217 .entry(import_name.to_string())
218 .or_default()
219 .push(info.clone());
220 }
221 }
222
223 let (conflicts, prepared): (Vec<_>, Vec<_>) =
224 mappings.into_iter().partition(|(_, infos)| infos.len() > 1);
225
226 if !conflicts.is_empty() {
227 return Err(ComposeError::DependencyConflicts {
228 component_id: component.id.clone(),
229 conflicts: conflicts
230 .into_iter()
231 .map(|(import_name, infos)| {
232 (
233 import_name,
234 infos.into_iter().map(|info| info.manifest_name).collect(),
235 )
236 })
237 .collect(),
238 });
239 }
240
241 Ok(prepared
242 .into_iter()
243 .map(|(import_name, mut infos)| {
244 assert_eq!(infos.len(), 1);
245 (import_name, infos.remove(0))
246 })
247 .collect())
248 }
249
250 async fn build_instantiation_arguments(
255 &mut self,
256 world_id: WorldId,
257 dependencies: IndexMap<String, DependencyInfo>,
258 ) -> Result<IndexMap<String, NodeId>, ComposeError> {
259 let mut cache = Default::default();
260 let mut checker = SubtypeChecker::new(&mut cache);
261
262 let mut arguments = IndexMap::new();
263
264 for (import_name, dependency_info) in dependencies {
265 let (export_name, export_ty) = match dependency_info.export_name {
266 Some(export_name) => {
267 let Some(export_ty) = self.graph.types()[dependency_info.world_id]
268 .exports
269 .get(&export_name)
270 else {
271 return Err(ComposeError::MissingExport {
272 dependency_name: dependency_info.manifest_name,
273 export_name,
274 import_name: import_name.clone(),
275 });
276 };
277
278 (export_name, export_ty)
279 }
280 None => {
281 let Some(export_ty) = self.graph.types()[dependency_info.world_id]
282 .exports
283 .get(&import_name)
284 else {
285 return Err(ComposeError::MissingExport {
286 dependency_name: dependency_info.manifest_name,
287 export_name: import_name.clone(),
288 import_name: import_name.clone(),
289 });
290 };
291
292 (import_name.clone(), export_ty)
293 }
294 };
295
296 let import_ty = self.graph.types()[world_id]
297 .imports
298 .get(&import_name)
299 .unwrap();
300
301 checker.is_subtype(
303 *export_ty,
304 self.graph.types(),
305 *import_ty,
306 self.graph.types(),
307 ).with_context(|| {
308 format!(
309 "dependency '{dependency_name}' exports '{export_name}' which is not compatible with import '{import_name}'",
310 dependency_name = dependency_info.manifest_name,
311 )
312 })
313 .map_err(ComposeError::PrepareError)?;
314
315 let export_id = self
316 .graph
317 .alias_instance_export(dependency_info.instantiation_id, &import_name)
318 .map_err(|e| ComposeError::PrepareError(e.into()))?;
319
320 assert!(arguments.insert(import_name, export_id).is_none());
321 }
322
323 Ok(arguments)
324 }
325
326 async fn register_dependency(
331 &mut self,
332 dependency_name: DependencyName,
333 dependency: &LockedComponentDependency,
334 ) -> anyhow::Result<DependencyInfo> {
335 let mut dependency_source = self
336 .loader
337 .load_component_source(&dependency.source)
338 .await?;
339
340 let package_name = match &dependency_name {
341 DependencyName::Package(name) => name.package.to_string(),
342 DependencyName::Plain(name) => name.to_string(),
343 };
344
345 match &dependency.inherit {
346 InheritConfiguration::Some(configurations) => {
347 if configurations.is_empty() {
348 dependency_source = apply_deny_all_adapter(&package_name, &dependency_source)?;
350 } else {
351 panic!("granular configuration inheritance is not yet supported");
352 }
353 }
354 InheritConfiguration::All => {
355 }
357 }
358
359 let (world_id, instantiation_id) =
360 self.register_package(&package_name, None, dependency_source)?;
361
362 Ok(DependencyInfo {
363 manifest_name: dependency_name,
364 instantiation_id,
365 world_id,
366 export_name: dependency.export.clone(),
367 })
368 }
369
370 fn register_package(
371 &mut self,
372 name: &str,
373 version: Option<&Version>,
374 source: impl Into<Vec<u8>>,
375 ) -> anyhow::Result<(WorldId, NodeId)> {
376 let package = Package::from_bytes(name, version, source, self.graph.types_mut())?;
377 let world_id = package.ty();
378 let package_id = self.graph.register_package(package)?;
379 let instantiation_id = self.graph.instantiate(package_id);
380
381 Ok((world_id, instantiation_id))
382 }
383
384 fn export_dependents_exports(
385 &mut self,
386 world_id: WorldId,
387 instantiation_id: NodeId,
388 ) -> anyhow::Result<()> {
389 for export_name in self.graph.types()[world_id]
391 .exports
392 .keys()
393 .cloned()
394 .collect::<Vec<_>>()
395 {
396 let export_id = self
397 .graph
398 .alias_instance_export(instantiation_id, &export_name)?;
399
400 self.graph.export(export_id, &export_name)?;
401 }
402
403 Ok(())
404 }
405}
406
407#[derive(Clone)]
408struct DependencyInfo {
409 manifest_name: DependencyName,
413 instantiation_id: NodeId,
415 world_id: WorldId,
417 export_name: Option<String>,
419}
420
421fn apply_deny_all_adapter(
422 dependency_name: &str,
423 dependency_source: &[u8],
424) -> anyhow::Result<Vec<u8>> {
425 const SPIN_VIRT_DENY_ALL_ADAPTER_BYTES: &[u8] = include_bytes!("../deny_all.wasm");
426 let mut graph = CompositionGraph::new();
427
428 let dependency_package =
429 Package::from_bytes(dependency_name, None, dependency_source, graph.types_mut())?;
430
431 let dependency_id = graph.register_package(dependency_package)?;
432
433 let deny_adapter_package = Package::from_bytes(
434 "spin-virt-deny-all-adapter",
435 None,
436 SPIN_VIRT_DENY_ALL_ADAPTER_BYTES,
437 graph.types_mut(),
438 )?;
439
440 let deny_adapter_id = graph.register_package(deny_adapter_package)?;
441
442 match wac_graph::plug(&mut graph, vec![deny_adapter_id], dependency_id) {
443 Err(wac_graph::PlugError::NoPlugHappened) => {
444 return Ok(dependency_source.to_vec());
447 }
448 Err(other) => {
449 anyhow::bail!(
450 "failed to plug deny-all adapter into dependency: {:?}",
451 other
452 );
453 }
454 Ok(_) => {}
455 }
456
457 let bytes = graph.encode(Default::default())?;
458 Ok(bytes)
459}
460
461enum ImportName {
462 Plain(KebabId),
463 Package {
464 package: String,
465 interface: String,
466 version: Option<Version>,
467 },
468}
469
470impl std::str::FromStr for ImportName {
471 type Err = anyhow::Error;
472
473 fn from_str(s: &str) -> Result<Self, Self::Err> {
474 if s.contains([':', '/']) {
475 let (package, rest) = s
476 .split_once('/')
477 .with_context(|| format!("invalid import name: {}", s))?;
478
479 let (interface, version) = match rest.split_once('@') {
480 Some((interface, version)) => {
481 let version = Version::parse(version)
482 .with_context(|| format!("invalid version in import name: {}", s))?;
483
484 (interface, Some(version))
485 }
486 None => (rest, None),
487 };
488
489 Ok(Self::Package {
490 package: package.to_string(),
491 interface: interface.to_string(),
492 version,
493 })
494 } else {
495 Ok(Self::Plain(
496 s.to_string()
497 .try_into()
498 .map_err(|e| anyhow::anyhow!("{e}"))?,
499 ))
500 }
501 }
502}
503
504fn matches_import(dependency_name: &DependencyName, import_name: &str) -> anyhow::Result<bool> {
506 let import_name = import_name.parse::<ImportName>()?;
507
508 match (dependency_name, import_name) {
509 (DependencyName::Plain(dependency_name), ImportName::Plain(import_name)) => {
510 Ok(dependency_name == &import_name)
512 }
513 (
514 DependencyName::Package(dependency_name),
515 ImportName::Package {
516 package: import_package,
517 interface: import_interface,
518 version: import_version,
519 },
520 ) => {
521 if import_package != dependency_name.package.to_string() {
522 return Ok(false);
523 }
524
525 if let Some(interface) = dependency_name.interface.as_ref() {
526 if import_interface != interface.as_ref() {
527 return Ok(false);
528 }
529 }
530
531 if let Some(version) = dependency_name.version.as_ref() {
532 if import_version != Some(version.clone()) {
533 return Ok(false);
534 }
535 }
536
537 Ok(true)
538 }
539 (_, _) => {
540 Ok(false)
542 }
543 }
544}
545
546#[cfg(test)]
547mod test {
548 use super::*;
549
550 #[test]
551 fn test_matches_import() {
552 for (dep_name, import_names) in [
553 ("foo:bar/baz@0.1.0", vec!["foo:bar/baz@0.1.0"]),
554 ("foo:bar/baz", vec!["foo:bar/baz@0.1.0", "foo:bar/baz"]),
555 ("foo:bar", vec!["foo:bar/baz@0.1.0", "foo:bar/baz"]),
556 ("foo:bar@0.1.0", vec!["foo:bar/baz@0.1.0"]),
557 ("foo-bar", vec!["foo-bar"]),
558 ] {
559 let dep_name: DependencyName = dep_name.parse().unwrap();
560 for import_name in import_names {
561 assert!(matches_import(&dep_name, import_name).unwrap());
562 }
563 }
564
565 for (dep_name, import_names) in [
566 ("foo:bar/baz@0.1.0", vec!["foo:bar/baz"]),
567 ("foo:bar/baz", vec!["foo:bar/bub", "foo:bar/bub@0.1.0"]),
568 ("foo:bar", vec!["foo:bub/bib"]),
569 ("foo:bar@0.1.0", vec!["foo:bar/baz"]),
570 ("foo:bar/baz", vec!["foo:bar/baz-bub", "foo-bar"]),
571 ] {
572 let dep_name: DependencyName = dep_name.parse().unwrap();
573 for import_name in import_names {
574 assert!(!matches_import(&dep_name, import_name).unwrap());
575 }
576 }
577 }
578}