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_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            // Limit concurrency to avoid hitting system resource limits
45            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    // Load the manifest file (spin.toml) at the given path into a LockedApp,
51    // preparing all its content for execution.
52    pub async fn load_file(&self, path: impl AsRef<Path>) -> Result<LockedApp> {
53        // Parse manifest
54        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        // Set origin metadata
67        locked
68            .metadata
69            .insert("origin".into(), file_url(path)?.into());
70
71        Ok(locked)
72    }
73
74    // Load the given manifest into a LockedApp, ready for execution.
75    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        // Load all components concurrently
109        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    // Load the given component into a LockedComponent, ready for execution.
144    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                    // Copy mounted files into component mount root, concurrently
189                    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                    // All component files (copies) are in `component_mount_root` now
195                    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                        // Validate (and canonicalize) direct mount directory
208                        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    // Load a Wasm source from the given ContentRef and update the source
297    // URL with an absolute path to the content.
298    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    // Copy content(s) from the given `mount`
314    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    // Copy files matching glob pattern or single file/directory path.
338    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                // "single/dir"
363                let pattern = path.join("**/*");
364                self.copy_glob(&pattern, &self.app_root, &dest, exclude_files)
365                    .await?;
366            } else {
367                // "single/file.txt"
368                self.copy_single_file(&path, &dest, glob_or_path).await?;
369            }
370        } else if looks_like_glob_pattern(glob_or_path) {
371            // "glob/pattern/*"
372            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    // Copy a single file or entire directory from `src` to `dest`
381    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            // { source = "host/dir", destination = "guest/dir" }
394            let pattern = src_path.join("**/*");
395            self.copy_glob(&pattern, &src_path, dest, exclude_files)
396                .await?;
397        } else {
398            // { source = "host/file.txt", destination = "guest/file.txt" }
399            self.copy_single_file(&src_path, dest, guest_dest).await?;
400        }
401        Ok(())
402    }
403
404    // Copy files matching glob `pattern` into `dest_root`.
405    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    // Copy a single file from `src` to `dest`, creating parent directories.
465    async fn copy_single_file(&self, src: &Path, dest: &Path, guest_dest: &str) -> Result<()> {
466        // Sanity checks: src is in app_root...
467        src.strip_prefix(&self.app_root)?;
468        // ...and dest is in the Copy root.
469        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    /// Does a guest path appear to be a directory name, e.g. "/" or ".."? This is for guest
517    /// paths *only* and does not consider Windows separators.
518    fn is_directory_like(guest_path: &str) -> bool {
519        guest_path.ends_with('/') || guest_path.ends_with('.') || guest_path.ends_with("..")
520    }
521
522    // Resolve the given direct mount directory, checking that it is valid for
523    // direct mounting and returning its canonicalized source path.
524    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    // Duplicate single-trigger global options into "trigger" with "type"
592    // key to maintain backward compatibility for a while.
593    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        // Flatten trigger config `components` `OneOrManyComponentSpecs` into
634        // lists of component references.
635        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)]
664/// Handles loading of component Wasm from different sources.
665pub struct WasmLoader {
666    app_root: PathBuf,
667    cache: Cache,
668    file_loading_permits: std::sync::Arc<Semaphore>,
669}
670
671impl WasmLoader {
672    /// Create a new instance of WasmLoader.
673    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    /// Load a Wasm source from the given ComponentSource and return a path
689    /// to a file location from where it can be read.
690    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    // Load a Wasm source from the given HTTP ContentRef source URL and
716    // return a ContentRef an absolute path to the local copy.
717    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    /// Loads a dependency
803    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                // This `unwrap()` should be OK because we've already validated
813                // this form of dependency requires a package name, i.e. the
814                // dependency name is not a kebab id.
815                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                        // This `unwrap()` should be OK because we've already validated
834                        // this form of dependency requires a package name, i.e. the
835                        // dependency name is not a kebab id.
836                        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
893/// Determines if a component requires the host to support local
894/// service chaining.
895pub 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}