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