spin_loader/
local.rs

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            // Limit concurrency to avoid hitting system resource limits
43            file_loading_permits: Semaphore::new(crate::MAX_FILE_LOADING_CONCURRENCY),
44        })
45    }
46
47    // Load the manifest file (spin.toml) at the given path into a LockedApp,
48    // preparing all its content for execution.
49    pub async fn load_file(&self, path: impl AsRef<Path>) -> Result<LockedApp> {
50        // Parse manifest
51        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        // Set origin metadata
64        locked
65            .metadata
66            .insert("origin".into(), file_url(path)?.into());
67
68        Ok(locked)
69    }
70
71    // Load the given manifest into a LockedApp, ready for execution.
72    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        // Load all components concurrently
105        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    // Load the given component into a LockedComponent, ready for execution.
137    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                    // Copy mounted files into component mount root, concurrently
181                    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                    // All component files (copies) are in `component_mount_root` now
187                    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                        // Validate (and canonicalize) direct mount directory
200                        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                // This `unwrap()` should be OK because we've already validated
274                // this form of dependency requires a package name, i.e. the
275                // dependency name is not a kebab id.
276                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                        // This `unwrap()` should be OK because we've already validated
295                        // this form of dependency requires a package name, i.e. the
296                        // dependency name is not a kebab id.
297                        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    // Load a Wasm source from the given ContentRef and update the source
348    // URL with an absolute path to the content.
349    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    // Load a Wasm source from the given HTTP ContentRef source URL and
378    // return a ContentRef an absolute path to the local copy.
379    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    // Copy content(s) from the given `mount`
465    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    // Copy files matching glob pattern or single file/directory path.
489    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                // "single/dir"
514                let pattern = path.join("**/*");
515                self.copy_glob(&pattern, &self.app_root, &dest, exclude_files)
516                    .await?;
517            } else {
518                // "single/file.txt"
519                self.copy_single_file(&path, &dest, glob_or_path).await?;
520            }
521        } else if looks_like_glob_pattern(glob_or_path) {
522            // "glob/pattern/*"
523            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    // Copy a single file or entire directory from `src` to `dest`
532    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            // { source = "host/dir", destination = "guest/dir" }
545            let pattern = src_path.join("**/*");
546            self.copy_glob(&pattern, &src_path, dest, exclude_files)
547                .await?;
548        } else {
549            // { source = "host/file.txt", destination = "guest/file.txt" }
550            self.copy_single_file(&src_path, dest, guest_dest).await?;
551        }
552        Ok(())
553    }
554
555    // Copy files matching glob `pattern` into `dest_root`.
556    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    // Copy a single file from `src` to `dest`, creating parent directories.
616    async fn copy_single_file(&self, src: &Path, dest: &Path, guest_dest: &str) -> Result<()> {
617        // Sanity checks: src is in app_root...
618        src.strip_prefix(&self.app_root)?;
619        // ...and dest is in the Copy root.
620        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    /// Does a guest path appear to be a directory name, e.g. "/" or ".."? This is for guest
668    /// paths *only* and does not consider Windows separators.
669    fn is_directory_like(guest_path: &str) -> bool {
670        guest_path.ends_with('/') || guest_path.ends_with('.') || guest_path.ends_with("..")
671    }
672
673    // Resolve the given direct mount directory, checking that it is valid for
674    // direct mounting and returning its canonicalized source path.
675    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    // Duplicate single-trigger global options into "trigger" with "type"
743    // key to maintain backward compatibility for a while.
744    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        // Flatten trigger config `components` `OneOrManyComponentSpecs` into
784        // lists of component references.
785        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}