1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result, anyhow, bail, ensure};
4use futures::{StreamExt, future::try_join_all};
5use reqwest::Url;
6use spin_common::{paths::parent_dir, sloth, ui::quoted_path};
7use spin_expressions::Resolver;
8use spin_locked_app::{
9 locked::{
10 self, ContentPath, ContentRef, LockedApp, LockedComponent, LockedComponentDependency,
11 LockedComponentSource, LockedTrigger,
12 },
13 values::{ValuesMap, ValuesMapBuilder},
14};
15use spin_manifest::schema::v2::{self, AppManifest, KebabId, WasiFilesMount};
16use spin_outbound_networking_config::allowed_hosts::{AllowedHostConfig, AllowedHostsConfig};
17use spin_serde::DependencyName;
18use std::collections::BTreeMap;
19use tokio::{io::AsyncWriteExt, sync::Semaphore};
20
21use crate::{FilesMountStrategy, cache::Cache};
22
23#[derive(Debug)]
24pub struct LocalLoader {
25 app_root: PathBuf,
26 files_mount_strategy: FilesMountStrategy,
27 file_loading_permits: std::sync::Arc<Semaphore>,
28 wasm_loader: WasmLoader,
29 profile: Option<String>,
30}
31
32impl LocalLoader {
33 pub async fn new(
34 app_root: &Path,
35 files_mount_strategy: FilesMountStrategy,
36 profile: Option<&str>,
37 cache_root: Option<PathBuf>,
38 ) -> Result<Self> {
39 let app_root = safe_canonicalize(app_root)
40 .with_context(|| format!("Invalid manifest dir `{}`", app_root.display()))?;
41 let file_loading_permits =
42 std::sync::Arc::new(Semaphore::new(crate::MAX_FILE_LOADING_CONCURRENCY));
43 Ok(Self {
44 app_root: app_root.clone(),
45 files_mount_strategy,
46 file_loading_permits: file_loading_permits.clone(),
48 wasm_loader: WasmLoader::new(app_root, cache_root, Some(file_loading_permits)).await?,
49 profile: profile.map(|s| s.to_owned()),
50 })
51 }
52
53 pub async fn load_file(&self, path: impl AsRef<Path>) -> Result<LockedApp> {
56 let path = path.as_ref();
58 let manifest = spin_manifest::manifest_from_file(path).with_context(|| {
59 format!(
60 "Failed to read Spin app manifest from {}",
61 quoted_path(path)
62 )
63 })?;
64 let mut locked = self
65 .load_manifest(manifest, self.profile())
66 .await
67 .with_context(|| format!("Failed to load Spin app from {}", quoted_path(path)))?;
68
69 locked
71 .metadata
72 .insert("origin".into(), file_url(path)?.into());
73
74 if let Some(profile) = self.profile.as_ref() {
76 locked
77 .metadata
78 .insert("profile".into(), profile.as_str().into());
79 }
80
81 Ok(locked)
82 }
83
84 pub(crate) async fn load_manifest(
86 &self,
87 mut manifest: AppManifest,
88 profile: Option<&str>,
89 ) -> Result<LockedApp> {
90 spin_manifest::normalize::normalize_manifest(&mut manifest, profile)?;
91
92 manifest.validate_dependencies()?;
93
94 let AppManifest {
95 spin_manifest_version: _,
96 application,
97 variables,
98 triggers,
99 components,
100 } = manifest;
101
102 let metadata = locked_metadata(application, triggers.keys().cloned())?;
103
104 let variables = variables
105 .into_iter()
106 .map(|(name, v)| Ok((name.to_string(), locked_variable(v)?)))
107 .collect::<Result<BTreeMap<_, _>>>()?;
108 let resolver = Resolver::new(variables.clone())?;
109
110 let triggers = triggers
111 .into_iter()
112 .flat_map(|(trigger_type, configs)| {
113 configs
114 .into_iter()
115 .map(|trigger| locked_trigger(trigger_type.clone(), trigger))
116 .collect::<Vec<_>>()
117 })
118 .collect::<Result<Vec<_>>>()?;
119
120 let sloth_guard = warn_if_component_load_slothful();
121
122 let components = try_join_all(components.into_iter().map(|(id, c)| {
124 let resolver = &resolver;
125 async move {
126 self.load_component(&id, c, resolver)
127 .await
128 .with_context(|| format!("Failed to load component `{id}`"))
129 }
130 }))
131 .await?;
132
133 let host_requirements = spin_locked_app::values::ValuesMap::new();
134
135 let mut must_understand = vec![];
136 if !host_requirements.is_empty() {
137 must_understand.push(spin_locked_app::locked::MustUnderstand::HostRequirements);
138 }
139 if components.iter().any(|c| !c.host_requirements.is_empty()) {
140 must_understand
141 .push(spin_locked_app::locked::MustUnderstand::ComponentHostRequirements);
142 }
143
144 drop(sloth_guard);
145
146 Ok(LockedApp {
147 spin_lock_version: Default::default(),
148 metadata,
149 must_understand,
150 host_requirements,
151 variables,
152 triggers,
153 components,
154 })
155 }
156
157 async fn load_component(
159 &self,
160 id: &KebabId,
161 component: v2::Component,
162 resolver: &Resolver,
163 ) -> Result<LockedComponent> {
164 let allowed_outbound_hosts = component
165 .normalized_allowed_outbound_hosts()
166 .context("`allowed_http_hosts` is malformed")?;
167 AllowedHostsConfig::validate(&allowed_outbound_hosts, resolver)
168 .context("`allowed_outbound_hosts` is malformed")?;
169
170 let component_requires_service_chaining = requires_service_chaining(&component);
171
172 let metadata = ValuesMapBuilder::new()
173 .string("description", component.description)
174 .string_array("allowed_outbound_hosts", allowed_outbound_hosts)
175 .string_array("key_value_stores", component.key_value_stores)
176 .string_array("databases", component.sqlite_databases)
177 .string_array("ai_models", component.ai_models)
178 .serializable("build", component.build)?
179 .take();
180
181 let source = self
182 .load_component_source(id, component.source.clone())
183 .await
184 .with_context(|| format!("Failed to load Wasm source {}", component.source))?;
185
186 let dependencies = self
187 .load_component_dependencies(id, &component.dependencies)
188 .await?;
189
190 let env = component.environment.into_iter().collect();
191
192 let files = if component.files.is_empty() {
193 vec![]
194 } else {
195 match &self.files_mount_strategy {
196 FilesMountStrategy::Copy(files_mount_root) => {
197 let component_mount_root = files_mount_root.join(id.as_ref());
198 try_join_all(component.files.iter().map(|f| {
200 self.copy_file_mounts(f, &component_mount_root, &component.exclude_files)
201 }))
202 .await?;
203
204 vec![ContentPath {
206 content: file_content_ref(component_mount_root)?,
207 path: "/".into(),
208 }]
209 }
210 FilesMountStrategy::Direct => {
211 ensure!(
212 component.exclude_files.is_empty(),
213 "Cannot load a component with `exclude_files` using --direct-mounts"
214 );
215 let mut files = vec![];
216 for mount in &component.files {
217 files.push(self.resolve_direct_mount(mount).await?);
219 }
220 files
221 }
222 }
223 };
224
225 let config = component
226 .variables
227 .into_iter()
228 .map(|(k, v)| (k.into(), v))
229 .collect();
230
231 let mut host_requirements = ValuesMapBuilder::new();
232 if component_requires_service_chaining {
233 host_requirements.string(
234 spin_locked_app::locked::SERVICE_CHAINING_KEY,
235 spin_locked_app::locked::HOST_REQ_REQUIRED,
236 );
237 }
238 let host_requirements = host_requirements.build();
239
240 Ok(LockedComponent {
241 id: id.as_ref().into(),
242 metadata,
243 source,
244 env,
245 files,
246 config,
247 dependencies,
248 host_requirements,
249 })
250 }
251
252 async fn load_component_dependencies(
253 &self,
254 id: &KebabId,
255 dependencies: &v2::ComponentDependencies,
256 ) -> Result<BTreeMap<DependencyName, LockedComponentDependency>> {
257 Ok(try_join_all(dependencies.inner.iter().map(
258 |(dependency_name, dependency)| async move {
259 let locked_dependency = self
260 .load_component_dependency(dependency_name.clone(), dependency.clone())
261 .await
262 .with_context(|| {
263 format!(
264 "Failed to load component dependency `{dependency_name}` for `{id}`"
265 )
266 })?;
267
268 anyhow::Ok((dependency_name.clone(), locked_dependency))
269 },
270 ))
271 .await?
272 .into_iter()
273 .collect())
274 }
275
276 async fn load_component_dependency(
277 &self,
278 dependency_name: DependencyName,
279 dependency: v2::ComponentDependency,
280 ) -> Result<LockedComponentDependency> {
281 self.wasm_loader
282 .load_component_dependency(&dependency_name, &dependency)
283 .await
284 }
285
286 async fn load_component_source(
289 &self,
290 component_id: &KebabId,
291 source: v2::ComponentSource,
292 ) -> Result<LockedComponentSource> {
293 let path = self
294 .wasm_loader
295 .load_component_source(component_id.as_ref(), &source)
296 .await?;
297 Ok(LockedComponentSource {
298 content_type: "application/wasm".into(),
299 content: file_content_ref(path)?,
300 })
301 }
302
303 async fn copy_file_mounts(
305 &self,
306 mount: &WasiFilesMount,
307 dest_root: &Path,
308 exclude_files: &[String],
309 ) -> Result<()> {
310 match mount {
311 WasiFilesMount::Pattern(pattern) => {
312 self.copy_glob_or_path(pattern, dest_root, exclude_files)
313 .await
314 }
315 WasiFilesMount::Placement {
316 source,
317 destination,
318 } => {
319 let src = Path::new(source);
320 let dest = dest_root.join(destination.trim_start_matches('/'));
321 self.copy_file_or_directory(src, &dest, destination, exclude_files)
322 .await
323 }
324 }
325 }
326
327 async fn copy_glob_or_path(
329 &self,
330 glob_or_path: &str,
331 dest_root: &Path,
332 exclude_files: &[String],
333 ) -> Result<()> {
334 if glob_or_path == ".." || glob_or_path.ends_with("/..") {
335 bail!(
336 "A file pattern can't end in a parent directory path (..)\nIf you want to include a directory, use source-destination form, or a glob pattern ending in **/*.\nLearn more: https://spinframework.dev/writing-apps#including-files-with-components"
337 );
338 }
339 if glob_or_path == "." || glob_or_path.ends_with("/.") {
340 bail!(
341 "A file pattern can't end in a current directory path (.)\nIf you want to include a directory, use source-destination form, or a glob pattern ending in **/*.\nLearn more: https://spinframework.dev/writing-apps#including-files-with-components"
342 );
343 }
344
345 if glob_or_path == "*" {
346 tracing::warn!(
347 alert_in_dev = true,
348 "A component is including the entire application directory as asset files. This is unlikely to be what you want.\nIf this is what you want, use the pattern \"./*\" to avoid this warning.\nLearn more: https://spinframework.dev/writing-apps#including-files-with-components\n"
349 );
350 }
351 if glob_or_path == "**/*" {
352 tracing::warn!(
353 alert_in_dev = true,
354 "A component is including the entire application directory tree as asset files. This is unlikely to be what you want.\nIf this is what you want, use the pattern \"./**/*\" to avoid this warning.\nLearn more: https://spinframework.dev/writing-apps#including-files-with-components\n"
355 );
356 }
357
358 let path = self.app_root.join(glob_or_path);
359 if path.exists() {
360 let dest = dest_root.join(glob_or_path);
361 if path.is_dir() {
362 let pattern = path.join("**/*");
364 self.copy_glob(&pattern, &self.app_root, &dest, exclude_files)
365 .await?;
366 } else {
367 self.copy_single_file(&path, &dest, glob_or_path).await?;
369 }
370 } else if looks_like_glob_pattern(glob_or_path) {
371 self.copy_glob(&path, &self.app_root, dest_root, exclude_files)
373 .await?;
374 } else {
375 bail!("{glob_or_path:?} does not exist and doesn't appear to be a glob pattern");
376 }
377 Ok(())
378 }
379
380 async fn copy_file_or_directory(
382 &self,
383 src: &Path,
384 dest: &Path,
385 guest_dest: &str,
386 exclude_files: &[String],
387 ) -> Result<()> {
388 let src_path = self.app_root.join(src);
389 let meta = crate::fs::metadata(&src_path)
390 .await
391 .map_err(|e| explain_file_mount_source_error(e, src))?;
392 if meta.is_dir() {
393 let pattern = src_path.join("**/*");
395 self.copy_glob(&pattern, &src_path, dest, exclude_files)
396 .await?;
397 } else {
398 self.copy_single_file(&src_path, dest, guest_dest).await?;
400 }
401 Ok(())
402 }
403
404 async fn copy_glob(
406 &self,
407 pattern: &Path,
408 src_prefix: &Path,
409 dest_root: &Path,
410 exclude_files: &[String],
411 ) -> Result<()> {
412 let pattern = pattern
413 .to_str()
414 .with_context(|| format!("invalid (non-utf8) file pattern {pattern:?}"))?;
415
416 let paths = glob::glob(pattern)
417 .with_context(|| format!("Failed to resolve glob pattern {pattern:?}"))?;
418
419 let exclude_patterns = exclude_files
420 .iter()
421 .map(|pattern| {
422 glob::Pattern::new(pattern)
423 .with_context(|| format!("Invalid exclude_files glob pattern {pattern:?}"))
424 })
425 .collect::<Result<Vec<_>>>()?;
426
427 crate::fs::create_dir_all(dest_root)
428 .await
429 .with_context(|| {
430 format!(
431 "Failed to create parent directory {}",
432 quoted_path(&dest_root)
433 )
434 })?;
435
436 for path_res in paths {
437 let src = path_res?;
438 if !src.is_file() {
439 continue;
440 }
441
442 let Ok(app_root_path) = src.strip_prefix(&self.app_root) else {
443 bail!(
444 "{pattern} cannot be mapped because it is outside the application directory. Files must be within the application directory."
445 );
446 };
447
448 if exclude_patterns
449 .iter()
450 .any(|pattern| pattern.matches_path(app_root_path))
451 {
452 tracing::debug!(
453 "File {app_root_path:?} excluded by exclude_files {exclude_files:?}"
454 );
455 continue;
456 }
457
458 let relative_path = src.strip_prefix(src_prefix)?;
459 let dest = dest_root.join(relative_path);
460 self.copy_single_file(&src, &dest, &relative_path.to_string_lossy())
461 .await?;
462 }
463 Ok(())
464 }
465
466 async fn copy_single_file(&self, src: &Path, dest: &Path, guest_dest: &str) -> Result<()> {
468 src.strip_prefix(&self.app_root)?;
470 if let FilesMountStrategy::Copy(files_mount_root) = &self.files_mount_strategy {
472 dest.strip_prefix(files_mount_root)?;
473 } else {
474 unreachable!();
475 }
476
477 let _loading_permit = self.file_loading_permits.acquire().await?;
478 let dest_parent = parent_dir(dest)?;
479 crate::fs::create_dir_all(&dest_parent)
480 .await
481 .with_context(|| {
482 format!(
483 "Failed to create parent directory {}",
484 quoted_path(&dest_parent)
485 )
486 })?;
487 crate::fs::copy(src, dest)
488 .await
489 .or_else(|e| Self::failed_to_copy_single_file_error(src, dest, guest_dest, e))?;
490 tracing::debug!("Copied {src:?} to {dest:?}");
491 Ok(())
492 }
493
494 fn failed_to_copy_single_file_error<T>(
495 src: &Path,
496 dest: &Path,
497 guest_dest: &str,
498 e: anyhow::Error,
499 ) -> anyhow::Result<T> {
500 let src_text = quoted_path(src);
501 let dest_text = quoted_path(dest);
502 let base_msg = format!("Failed to copy {src_text} to working path {dest_text}");
503
504 if let Some(io_error) = e.downcast_ref::<std::io::Error>()
505 && (Self::is_directory_like(guest_dest)
506 || io_error.kind() == std::io::ErrorKind::NotFound)
507 {
508 return Err(anyhow::anyhow!(
509 r#""{guest_dest}" is not a valid destination file name"#
510 ))
511 .context(base_msg);
512 }
513
514 Err(e).with_context(|| format!("{base_msg} (for destination path \"{guest_dest}\")"))
515 }
516
517 fn is_directory_like(guest_path: &str) -> bool {
520 guest_path.ends_with('/') || guest_path.ends_with('.') || guest_path.ends_with("..")
521 }
522
523 async fn resolve_direct_mount(&self, mount: &WasiFilesMount) -> Result<ContentPath> {
526 let (src, dest) = match mount {
527 WasiFilesMount::Pattern(pattern) => (pattern, pattern),
528 WasiFilesMount::Placement {
529 source,
530 destination,
531 } => (source, destination),
532 };
533 let path = self.app_root.join(src);
534 if !path.is_dir() {
535 bail!(
536 "Only directory mounts are supported with `--direct-mounts`; {src:?} is not a directory."
537 );
538 }
539 Ok(ContentPath {
540 content: file_content_ref(src)?,
541 path: dest.into(),
542 })
543 }
544
545 fn profile(&self) -> Option<&str> {
546 self.profile.as_deref()
547 }
548}
549
550fn explain_file_mount_source_error(e: anyhow::Error, src: &Path) -> anyhow::Error {
551 if let Some(io_error) = e.downcast_ref::<std::io::Error>()
552 && io_error.kind() == std::io::ErrorKind::NotFound
553 {
554 return anyhow::anyhow!("File or directory {} does not exist", quoted_path(src));
555 }
556 e.context(format!("invalid file mount source {}", quoted_path(src)))
557}
558
559#[cfg(feature = "async-io")]
560async fn verified_download(
561 url: &str,
562 digest: &str,
563 dest: &Path,
564 convention: crate::http::DestinationConvention,
565) -> Result<()> {
566 crate::http::verified_download(url, digest, dest, convention)
567 .await
568 .with_context(|| format!("Error fetching source URL {url:?}"))
569}
570
571#[cfg(not(feature = "async-io"))]
572async fn verified_download(
573 _url: &str,
574 _digest: &str,
575 _dest: &Path,
576 _convention: crate::http::DestinationConvention,
577) -> Result<()> {
578 panic!("async-io feature is required for downloading Wasm sources")
579}
580
581fn safe_canonicalize(path: &Path) -> std::io::Result<PathBuf> {
582 use path_absolutize::Absolutize;
583 Ok(path.absolutize()?.into_owned())
584}
585
586fn locked_metadata(
587 details: v2::AppDetails,
588 trigger_types: impl Iterator<Item = String>,
589) -> Result<ValuesMap> {
590 let mut builder = ValuesMapBuilder::new();
591 builder
592 .string("name", details.name)
593 .string("version", details.version)
594 .string("description", details.description)
595 .string_array("authors", details.authors)
596 .serializable("triggers", &details.trigger_global_configs)?;
597
598 let types = trigger_types.collect::<Vec<_>>();
601 if types.len() == 1 {
602 let trigger_type = types.into_iter().next().unwrap();
603 let mut single_trigger = details
604 .trigger_global_configs
605 .get(&trigger_type)
606 .cloned()
607 .unwrap_or_default();
608 single_trigger.insert("type".into(), trigger_type.into());
609 builder.serializable("trigger", single_trigger).unwrap();
610 }
611
612 Ok(builder.build())
613}
614
615fn locked_variable(variable: v2::Variable) -> Result<locked::Variable> {
616 ensure!(
617 variable.required ^ variable.default.is_some(),
618 "must be `required` OR have a `default`"
619 );
620 Ok(locked::Variable {
621 description: variable.description,
622 default: variable.default.clone(),
623 secret: variable.secret,
624 })
625}
626
627fn locked_trigger(trigger_type: String, trigger: v2::Trigger) -> Result<LockedTrigger> {
628 fn reference_id(spec: v2::ComponentSpec) -> toml::Value {
629 let v2::ComponentSpec::Reference(id) = spec else {
630 unreachable!("should have already been normalized");
631 };
632 id.as_ref().into()
633 }
634
635 let mut config = trigger.config;
636 if let Some(id) = trigger.component.map(reference_id) {
637 config.insert("component".into(), id);
638 }
639 if !trigger.components.is_empty() {
640 config.insert(
643 "components".into(),
644 trigger
645 .components
646 .into_iter()
647 .map(|(key, specs)| {
648 (
649 key,
650 specs
651 .0
652 .into_iter()
653 .map(reference_id)
654 .collect::<Vec<_>>()
655 .into(),
656 )
657 })
658 .collect::<toml::Table>()
659 .into(),
660 );
661 }
662
663 Ok(LockedTrigger {
664 id: trigger.id,
665 trigger_type,
666 trigger_config: config.try_into()?,
667 })
668}
669
670#[derive(Debug)]
671pub struct WasmLoader {
673 app_root: PathBuf,
674 cache: Cache,
675 file_loading_permits: std::sync::Arc<Semaphore>,
676}
677
678impl WasmLoader {
679 pub async fn new(
681 app_root: PathBuf,
682 cache_root: Option<PathBuf>,
683 file_loading_permits: Option<std::sync::Arc<Semaphore>>,
684 ) -> Result<Self> {
685 let file_loading_permits = file_loading_permits.unwrap_or_else(|| {
686 std::sync::Arc::new(Semaphore::new(crate::MAX_FILE_LOADING_CONCURRENCY))
687 });
688 Ok(Self {
689 app_root,
690 cache: Cache::new(cache_root).await?,
691 file_loading_permits,
692 })
693 }
694
695 pub async fn load_component_source(
698 &self,
699 component_id: &str,
700 source: &v2::ComponentSource,
701 ) -> Result<PathBuf> {
702 let content = match source {
703 v2::ComponentSource::Local(path) => self.app_root.join(path),
704 v2::ComponentSource::Remote { url, digest } => {
705 self.load_http_source(url, digest).await?
706 }
707 v2::ComponentSource::Registry {
708 registry,
709 package,
710 version,
711 } => {
712 let version = semver::Version::parse(version).with_context(|| format!("Component {component_id} specifies an invalid semantic version ({version:?}) for its package version"))?;
713 let version_req = format!("={version}").parse().expect("version");
714
715 self.load_registry_source(registry.as_ref(), package, &version_req)
716 .await?
717 }
718 };
719 Ok(content)
720 }
721
722 async fn load_http_source(&self, url: &str, digest: &str) -> Result<PathBuf> {
725 ensure!(
726 digest.starts_with("sha256:"),
727 "invalid `digest` {digest:?}; must start with 'sha256:'"
728 );
729 let path = if let Ok(cached_path) = self.cache.wasm_file(digest) {
730 cached_path
731 } else {
732 let _loading_permit = self.file_loading_permits.acquire().await?;
733
734 self.cache.ensure_dirs().await?;
735 let dest = self.cache.wasm_path(digest);
736 verified_download(
737 url,
738 digest,
739 &dest,
740 crate::http::DestinationConvention::ContentIndexed,
741 )
742 .await
743 .with_context(|| format!("Error fetching source URL {url:?}"))?;
744 dest
745 };
746 Ok(path)
747 }
748
749 async fn load_registry_source(
750 &self,
751 registry: Option<&wasm_pkg_client::Registry>,
752 package: &wasm_pkg_client::PackageRef,
753 version: &semver::VersionReq,
754 ) -> Result<PathBuf> {
755 let mut client_config = wasm_pkg_client::Config::global_defaults().await?;
756
757 if let Some(registry) = registry.cloned() {
758 let mapping = wasm_pkg_client::RegistryMapping::Registry(registry);
759 client_config.set_package_registry_override(package.clone(), mapping);
760 }
761 let pkg_loader = wasm_pkg_client::Client::new(client_config);
762
763 let mut releases = pkg_loader.list_all_versions(package).await.map_err(|e| {
764 if matches!(e, wasm_pkg_client::Error::NoRegistryForNamespace(_)) && registry.is_none() {
765 anyhow!("No default registry specified for wasm-pkg-loader. Create a default config, or set `registry` for package {package:?}")
766 } else {
767 e.into()
768 }
769 })?;
770
771 releases.sort();
772
773 let release_version = releases
774 .iter()
775 .rev()
776 .find(|release| version.matches(&release.version) && !release.yanked)
777 .with_context(|| format!("No matching version found for {package} {version}",))?;
778
779 let release = pkg_loader
780 .get_release(package, &release_version.version)
781 .await?;
782
783 let digest = match &release.content_digest {
784 wasm_pkg_client::ContentDigest::Sha256 { hex } => format!("sha256:{hex}"),
785 };
786
787 let path = if let Ok(cached_path) = self.cache.wasm_file(&digest) {
788 cached_path
789 } else {
790 let mut stm = pkg_loader.stream_content(package, &release).await?;
791
792 self.cache.ensure_dirs().await?;
793 let dest = self.cache.wasm_path(&digest);
794
795 let mut file = tokio::fs::File::create(&dest).await?;
796 while let Some(block) = stm.next().await {
797 let bytes = block.context("Failed to get content from registry")?;
798 file.write_all(&bytes)
799 .await
800 .context("Failed to save registry content to cache")?;
801 }
802
803 dest
804 };
805
806 Ok(path)
807 }
808
809 pub async fn load_component_dependency(
811 &self,
812 dependency_name: &DependencyName,
813 dependency: &v2::ComponentDependency,
814 ) -> Result<locked::LockedComponentDependency> {
815 let inherit = match dependency.inherit_configuration() {
816 Some(v2::InheritConfiguration::All(true)) => locked::InheritConfiguration::All,
817 Some(v2::InheritConfiguration::Some(keys)) => {
818 locked::InheritConfiguration::Some(keys.clone())
819 }
820 Some(v2::InheritConfiguration::All(false)) | None => {
821 locked::InheritConfiguration::Some(vec![])
822 }
823 };
824
825 let (content, export) = self
826 .load_dependency_content(dependency_name, dependency)
827 .await?;
828
829 Ok(locked::LockedComponentDependency {
830 source: locked::LockedComponentSource {
831 content_type: "application/wasm".into(),
832 content: file_content_ref(content)?,
833 },
834 export,
835 inherit,
836 })
837 }
838
839 pub async fn load_dependency_content(
841 &self,
842 dependency_name: &DependencyName,
843 dependency: &v2::ComponentDependency,
844 ) -> Result<(PathBuf, Option<String>)> {
845 match dependency.clone() {
846 v2::ComponentDependency::Version(version) => {
847 let version = semver::VersionReq::parse(&version).with_context(|| format!("Component dependency {dependency_name:?} specifies an invalid semantic version requirement ({version:?}) for its package version"))?;
848
849 let package = dependency_name.package().unwrap();
853
854 let content = self.load_registry_source(None, package, &version).await?;
855 Ok((content, None))
856 }
857 v2::ComponentDependency::Package {
858 version,
859 registry,
860 package,
861 export,
862 ..
863 } => {
864 let version = semver::VersionReq::parse(&version).with_context(|| format!("Component dependency {dependency_name:?} specifies an invalid semantic version requirement ({version:?}) for its package version"))?;
865
866 let package = match package {
867 Some(package) => {
868 package.parse().with_context(|| format!("Component dependency {dependency_name:?} specifies an invalid package name ({package:?})"))?
869 }
870 None => {
871 dependency_name
875 .package()
876 .cloned()
877 .unwrap()
878 }
879 };
880
881 let registry = match registry {
882 Some(registry) => {
883 registry
884 .parse()
885 .map(Some)
886 .with_context(|| format!("Component dependency {dependency_name:?} specifies an invalid registry name ({registry:?})"))?
887 }
888 None => None,
889 };
890
891 let content = self
892 .load_registry_source(registry.as_ref(), &package, &version)
893 .await?;
894 Ok((content, export))
895 }
896 v2::ComponentDependency::Local { path, export, .. } => {
897 let content = self.app_root.join(path);
898 Ok((content, export))
899 }
900 v2::ComponentDependency::HTTP {
901 url,
902 digest,
903 export,
904 ..
905 } => {
906 let content = self.load_http_source(&url, &digest).await?;
907 Ok((content, export))
908 }
909 v2::ComponentDependency::AppComponent { .. } => {
910 panic!("Internal error: component ID dependency was not resolved to a source");
911 }
912 }
913 }
914}
915
916fn looks_like_glob_pattern(s: impl AsRef<str>) -> bool {
917 let s = s.as_ref();
918 glob::Pattern::escape(s) != s
919}
920
921fn file_content_ref(path: impl AsRef<Path>) -> Result<ContentRef> {
922 Ok(ContentRef {
923 source: Some(file_url(path)?),
924 ..Default::default()
925 })
926}
927
928fn file_url(path: impl AsRef<Path>) -> Result<String> {
929 let path = path.as_ref();
930 let abs_path = safe_canonicalize(path)
931 .with_context(|| format!("Couldn't resolve `{}`", path.display()))?;
932 Ok(Url::from_file_path(abs_path).unwrap().to_string())
933}
934
935pub fn requires_service_chaining(component: &spin_manifest::schema::v2::Component) -> bool {
938 component
939 .normalized_allowed_outbound_hosts()
940 .unwrap_or_default()
941 .iter()
942 .any(|h| is_chaining_host(h))
943}
944
945fn is_chaining_host(pattern: &str) -> bool {
946 AllowedHostConfig::parse(pattern).is_ok_and(|config| config.is_for_service_chaining())
947}
948
949const SLOTH_WARNING_DELAY_MILLIS: u64 = 1250;
950
951fn warn_if_component_load_slothful() -> sloth::SlothGuard {
952 let message = "Loading Wasm components is taking a few seconds...";
953 sloth::warn_if_slothful(SLOTH_WARNING_DELAY_MILLIS, format!("{message}\n"))
954}
955
956#[cfg(test)]
957mod test {
958 use super::*;
959
960 #[tokio::test]
961 async fn bad_destination_filename_is_explained() -> anyhow::Result<()> {
962 let app_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
963 .join("tests")
964 .join("file-errors");
965 let wd = tempfile::tempdir()?;
966 let loader = LocalLoader::new(
967 &app_root,
968 FilesMountStrategy::Copy(wd.path().to_owned()),
969 None,
970 None,
971 )
972 .await?;
973 let err = loader
974 .load_file(app_root.join("bad.toml"))
975 .await
976 .expect_err("loader should not have succeeded");
977 let err_ctx = format!("{err:#}");
978 assert!(
979 err_ctx.contains(r#""/" is not a valid destination file name"#),
980 "expected error to show destination file name but got {err_ctx}",
981 );
982 Ok(())
983 }
984}