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