Skip to main content

spin_loader/
local.rs

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