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
23mod trigger_components;
24
25#[derive(Debug)]
26pub struct LocalLoader {
27    app_root: PathBuf,
28    files_mount_strategy: FilesMountStrategy,
29    file_loading_permits: std::sync::Arc<Semaphore>,
30    wasm_loader: WasmLoader,
31    profile: Option<String>,
32}
33
34impl LocalLoader {
35    pub async fn new(
36        app_root: &Path,
37        files_mount_strategy: FilesMountStrategy,
38        profile: Option<&str>,
39        cache_root: Option<PathBuf>,
40    ) -> Result<Self> {
41        let app_root = safe_canonicalize(app_root)
42            .with_context(|| format!("Invalid manifest dir `{}`", app_root.display()))?;
43        let file_loading_permits =
44            std::sync::Arc::new(Semaphore::new(crate::MAX_FILE_LOADING_CONCURRENCY));
45        Ok(Self {
46            app_root: app_root.clone(),
47            files_mount_strategy,
48            // Limit concurrency to avoid hitting system resource limits
49            file_loading_permits: file_loading_permits.clone(),
50            wasm_loader: WasmLoader::new(app_root, cache_root, Some(file_loading_permits)).await?,
51            profile: profile.map(|s| s.to_owned()),
52        })
53    }
54
55    // Load the manifest file (spin.toml) at the given path into a LockedApp,
56    // preparing all its content for execution.
57    pub async fn load_file(&self, path: impl AsRef<Path>) -> Result<LockedApp> {
58        // Parse manifest
59        let path = path.as_ref();
60        let manifest = spin_manifest::manifest_from_file(path).with_context(|| {
61            format!(
62                "Failed to read Spin app manifest from {}",
63                quoted_path(path)
64            )
65        })?;
66        let mut locked = self
67            .load_manifest(manifest, self.profile())
68            .await
69            .with_context(|| format!("Failed to load Spin app from {}", quoted_path(path)))?;
70
71        // Set origin metadata
72        locked
73            .metadata
74            .insert("origin".into(), file_url(path)?.into());
75
76        // Set build profile metadata
77        if let Some(profile) = self.profile.as_ref() {
78            locked
79                .metadata
80                .insert("profile".into(), profile.as_str().into());
81        }
82
83        Ok(locked)
84    }
85
86    // Load the given manifest into a LockedApp, ready for execution.
87    pub(crate) async fn load_manifest(
88        &self,
89        mut manifest: AppManifest,
90        profile: Option<&str>,
91    ) -> Result<LockedApp> {
92        spin_manifest::normalize::normalize_manifest(&mut manifest, profile)?;
93
94        manifest.validate_dependencies()?;
95
96        let AppManifest {
97            spin_manifest_version: _,
98            application,
99            variables,
100            triggers,
101            components,
102        } = manifest;
103
104        let metadata = locked_metadata(application, triggers.keys().cloned())?;
105
106        let variables = variables
107            .into_iter()
108            .map(|(name, v)| Ok((name.to_string(), locked_variable(v)?)))
109            .collect::<Result<BTreeMap<_, _>>>()?;
110        let resolver = Resolver::new(variables.clone())?;
111
112        let triggers = try_join_all(triggers.into_iter().flat_map(|(trigger_type, configs)| {
113            configs
114                .into_iter()
115                .map(|trigger| {
116                    let trigger_type_2 = trigger_type.clone();
117                    let id = trigger.id.clone();
118                    async move {
119                        self.load_trigger(&id, &trigger_type_2, trigger)
120                            .await
121                            .with_context(|| {
122                                format!("Failed to load `{}` trigger `{}`", trigger_type_2, id)
123                            })
124                    }
125                })
126                .collect::<Vec<_>>()
127        }))
128        .await?;
129
130        let sloth_guard = warn_if_component_load_slothful();
131
132        // Load all components concurrently
133        let components = try_join_all(components.into_iter().map(|(id, c)| {
134            let resolver = &resolver;
135            async move {
136                self.load_component(&id, c, resolver)
137                    .await
138                    .with_context(|| format!("Failed to load component `{id}`"))
139            }
140        }))
141        .await?;
142
143        let host_requirements = spin_locked_app::values::ValuesMap::new();
144
145        let mut must_understand = vec![];
146        if !host_requirements.is_empty() {
147            must_understand.push(spin_locked_app::locked::MustUnderstand::HostRequirements);
148        }
149        if components.iter().any(|c| !c.host_requirements.is_empty()) {
150            must_understand
151                .push(spin_locked_app::locked::MustUnderstand::ComponentHostRequirements);
152        }
153
154        drop(sloth_guard);
155
156        let locked = LockedApp {
157            spin_lock_version: Default::default(),
158            metadata,
159            must_understand,
160            host_requirements,
161            variables,
162            triggers,
163            components,
164        };
165
166        let locked = trigger_components::reassign_trigger_deps(locked);
167
168        Ok(locked)
169    }
170
171    async fn load_trigger(
172        &self,
173        id: &str,
174        trigger_type: &str,
175        trigger: v2::Trigger,
176    ) -> Result<LockedTrigger> {
177        fn reference_id(spec: v2::ComponentSpec) -> toml::Value {
178            let v2::ComponentSpec::Reference(id) = spec else {
179                unreachable!("should have already been normalized");
180            };
181            id.as_ref().into()
182        }
183
184        let mut config = trigger.config;
185
186        if let Some(id) = trigger.component.map(reference_id) {
187            config.insert("component".into(), id);
188        }
189
190        let locked_deps = if !trigger.dependencies.is_empty() {
191            self.load_trigger_dependencies(id, trigger.dependencies.iter())
192                .await?
193        } else {
194            BTreeMap::new()
195        };
196
197        Ok(LockedTrigger {
198            id: trigger.id,
199            trigger_type: trigger_type.to_string(),
200            trigger_config: config.try_into()?,
201            trigger_dependencies: locked_deps,
202        })
203    }
204
205    // Load the given component into a LockedComponent, ready for execution.
206    async fn load_component(
207        &self,
208        id: &KebabId,
209        component: v2::Component,
210        resolver: &Resolver,
211    ) -> Result<LockedComponent> {
212        let allowed_outbound_hosts = component
213            .normalized_allowed_outbound_hosts()
214            .context("`allowed_http_hosts` is malformed")?;
215        AllowedHostsConfig::validate(&allowed_outbound_hosts, resolver)
216            .context("`allowed_outbound_hosts` is malformed")?;
217
218        let component_requires_service_chaining = requires_service_chaining(&component);
219
220        let metadata = ValuesMapBuilder::new()
221            .string("description", component.description)
222            .string_array("allowed_outbound_hosts", allowed_outbound_hosts)
223            .string_array("key_value_stores", component.key_value_stores)
224            .string_array("databases", component.sqlite_databases)
225            .string_array("ai_models", component.ai_models)
226            .serializable("build", component.build)?
227            .take();
228
229        let source = self
230            .load_component_source(id, component.source.clone())
231            .await
232            .with_context(|| format!("Failed to load Wasm source {}", component.source))?;
233
234        let dependencies = self
235            .load_component_dependencies(id, &component.dependencies)
236            .await?;
237
238        let env = component.environment.into_iter().collect();
239
240        let files = if component.files.is_empty() {
241            vec![]
242        } else {
243            match &self.files_mount_strategy {
244                FilesMountStrategy::Copy(files_mount_root) => {
245                    let component_mount_root = files_mount_root.join(id.as_ref());
246                    // Copy mounted files into component mount root, concurrently
247                    try_join_all(component.files.iter().map(|f| {
248                        self.copy_file_mounts(f, &component_mount_root, &component.exclude_files)
249                    }))
250                    .await?;
251
252                    // All component files (copies) are in `component_mount_root` now
253                    vec![ContentPath {
254                        content: file_content_ref(component_mount_root)?,
255                        path: "/".into(),
256                    }]
257                }
258                FilesMountStrategy::Direct => {
259                    ensure!(
260                        component.exclude_files.is_empty(),
261                        "Cannot load a component with `exclude_files` using --direct-mounts"
262                    );
263                    let mut files = vec![];
264                    for mount in &component.files {
265                        // Validate (and canonicalize) direct mount directory
266                        files.push(self.resolve_direct_mount(mount).await?);
267                    }
268                    files
269                }
270            }
271        };
272
273        let config = component
274            .variables
275            .into_iter()
276            .map(|(k, v)| (k.into(), v))
277            .collect();
278
279        let mut host_requirements = ValuesMapBuilder::new();
280        if component_requires_service_chaining {
281            host_requirements.string(
282                spin_locked_app::locked::SERVICE_CHAINING_KEY,
283                spin_locked_app::locked::HOST_REQ_REQUIRED,
284            );
285        }
286        let host_requirements = host_requirements.build();
287
288        Ok(LockedComponent {
289            id: id.as_ref().into(),
290            metadata,
291            source,
292            env,
293            files,
294            config,
295            dependencies,
296            trigger_dependencies: Default::default(), // We will fix this up later
297            host_requirements,
298        })
299    }
300
301    async fn load_component_dependencies(
302        &self,
303        id: &KebabId,
304        dependencies: &v2::ComponentDependencies,
305    ) -> Result<BTreeMap<DependencyName, LockedComponentDependency>> {
306        Ok(try_join_all(dependencies.inner.iter().map(
307            |(dependency_name, dependency)| async move {
308                let locked_dependency = self
309                    .load_component_dependency(dependency_name.clone(), dependency.clone())
310                    .await
311                    .with_context(|| {
312                        format!(
313                            "Failed to load component dependency `{dependency_name}` for `{id}`"
314                        )
315                    })?;
316
317                anyhow::Ok((dependency_name.clone(), locked_dependency))
318            },
319        ))
320        .await?
321        .into_iter()
322        .collect())
323    }
324
325    async fn load_component_dependency(
326        &self,
327        dependency_name: DependencyName,
328        dependency: v2::ComponentDependency,
329    ) -> Result<LockedComponentDependency> {
330        self.wasm_loader
331            .load_component_dependency(&dependency_name, &dependency)
332            .await
333    }
334
335    async fn load_trigger_dependencies(
336        &self,
337        id: &str,
338        dependencies: impl Iterator<Item = (&String, &v2::TriggerDependencies)>,
339    ) -> Result<BTreeMap<String, Vec<LockedComponentDependency>>> {
340        let mut loaded = BTreeMap::new();
341
342        for (role, deps) in dependencies {
343            let locked_deps = self.load_trigger_dependencies_vec(id, &deps.0).await?;
344            loaded.insert(role.clone(), locked_deps);
345        }
346
347        Ok(loaded)
348    }
349
350    async fn load_trigger_dependencies_vec(
351        &self,
352        id: &str,
353        dependencies: &[v2::TriggerDependency],
354    ) -> Result<Vec<LockedComponentDependency>> {
355        Ok(
356            try_join_all(dependencies.iter().map(|dependency| async move {
357                let locked_dependency = self
358                    .load_trigger_dependency(dependency.clone())
359                    .await
360                    .with_context(|| {
361                        format!("Failed to load trigger dependency `{dependency:?}` for `{id}`")
362                    })?;
363
364                anyhow::Ok(locked_dependency)
365            }))
366            .await?
367            .into_iter()
368            .collect(),
369        )
370    }
371
372    async fn load_trigger_dependency(
373        &self,
374        dependency: v2::TriggerDependency,
375    ) -> Result<LockedComponentDependency> {
376        self.wasm_loader.load_trigger_dependency(&dependency).await
377    }
378
379    // Load a Wasm source from the given ContentRef and update the source
380    // URL with an absolute path to the content.
381    async fn load_component_source(
382        &self,
383        component_id: &KebabId,
384        source: v2::ComponentSource,
385    ) -> Result<LockedComponentSource> {
386        let path = self
387            .wasm_loader
388            .load_component_source(component_id.as_ref(), &source)
389            .await?;
390        Ok(LockedComponentSource {
391            content_type: "application/wasm".into(),
392            content: file_content_ref(path)?,
393        })
394    }
395
396    // Copy content(s) from the given `mount`
397    async fn copy_file_mounts(
398        &self,
399        mount: &WasiFilesMount,
400        dest_root: &Path,
401        exclude_files: &[String],
402    ) -> Result<()> {
403        match mount {
404            WasiFilesMount::Pattern(pattern) => {
405                self.copy_glob_or_path(pattern, dest_root, exclude_files)
406                    .await
407            }
408            WasiFilesMount::Placement {
409                source,
410                destination,
411            } => {
412                let src = Path::new(source);
413                let dest = dest_root.join(destination.trim_start_matches('/'));
414                self.copy_file_or_directory(src, &dest, destination, exclude_files)
415                    .await
416            }
417        }
418    }
419
420    // Copy files matching glob pattern or single file/directory path.
421    async fn copy_glob_or_path(
422        &self,
423        glob_or_path: &str,
424        dest_root: &Path,
425        exclude_files: &[String],
426    ) -> Result<()> {
427        if glob_or_path == ".." || glob_or_path.ends_with("/..") {
428            bail!(
429                "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"
430            );
431        }
432        if glob_or_path == "." || glob_or_path.ends_with("/.") {
433            bail!(
434                "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"
435            );
436        }
437
438        if glob_or_path == "*" {
439            tracing::warn!(
440                alert_in_dev = true,
441                "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"
442            );
443        }
444        if glob_or_path == "**/*" {
445            tracing::warn!(
446                alert_in_dev = true,
447                "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"
448            );
449        }
450
451        let path = self.app_root.join(glob_or_path);
452        if path.exists() {
453            let dest = dest_root.join(glob_or_path);
454            if path.is_dir() {
455                // "single/dir"
456                let pattern = path.join("**/*");
457                self.copy_glob(&pattern, &self.app_root, &dest, exclude_files)
458                    .await?;
459            } else {
460                // "single/file.txt"
461                self.copy_single_file(&path, &dest, glob_or_path).await?;
462            }
463        } else if looks_like_glob_pattern(glob_or_path) {
464            // "glob/pattern/*"
465            self.copy_glob(&path, &self.app_root, dest_root, exclude_files)
466                .await?;
467        } else {
468            bail!("{glob_or_path:?} does not exist and doesn't appear to be a glob pattern");
469        }
470        Ok(())
471    }
472
473    // Copy a single file or entire directory from `src` to `dest`
474    async fn copy_file_or_directory(
475        &self,
476        src: &Path,
477        dest: &Path,
478        guest_dest: &str,
479        exclude_files: &[String],
480    ) -> Result<()> {
481        let src_path = self.app_root.join(src);
482        let meta = crate::fs::metadata(&src_path)
483            .await
484            .map_err(|e| explain_file_mount_source_error(e, src))?;
485        if meta.is_dir() {
486            // { source = "host/dir", destination = "guest/dir" }
487            let pattern = src_path.join("**/*");
488            self.copy_glob(&pattern, &src_path, dest, exclude_files)
489                .await?;
490        } else {
491            // { source = "host/file.txt", destination = "guest/file.txt" }
492            self.copy_single_file(&src_path, dest, guest_dest).await?;
493        }
494        Ok(())
495    }
496
497    // Copy files matching glob `pattern` into `dest_root`.
498    async fn copy_glob(
499        &self,
500        pattern: &Path,
501        src_prefix: &Path,
502        dest_root: &Path,
503        exclude_files: &[String],
504    ) -> Result<()> {
505        let pattern = pattern
506            .to_str()
507            .with_context(|| format!("invalid (non-utf8) file pattern {pattern:?}"))?;
508
509        let paths = glob::glob(pattern)
510            .with_context(|| format!("Failed to resolve glob pattern {pattern:?}"))?;
511
512        let exclude_patterns = exclude_files
513            .iter()
514            .map(|pattern| {
515                glob::Pattern::new(pattern)
516                    .with_context(|| format!("Invalid exclude_files glob pattern {pattern:?}"))
517            })
518            .collect::<Result<Vec<_>>>()?;
519
520        crate::fs::create_dir_all(dest_root)
521            .await
522            .with_context(|| {
523                format!(
524                    "Failed to create parent directory {}",
525                    quoted_path(&dest_root)
526                )
527            })?;
528
529        for path_res in paths {
530            let src = path_res?;
531            if !src.is_file() {
532                continue;
533            }
534
535            let Ok(app_root_path) = src.strip_prefix(&self.app_root) else {
536                bail!(
537                    "{pattern} cannot be mapped because it is outside the application directory. Files must be within the application directory."
538                );
539            };
540
541            if exclude_patterns
542                .iter()
543                .any(|pattern| pattern.matches_path(app_root_path))
544            {
545                tracing::debug!(
546                    "File {app_root_path:?} excluded by exclude_files {exclude_files:?}"
547                );
548                continue;
549            }
550
551            let relative_path = src.strip_prefix(src_prefix)?;
552            let dest = dest_root.join(relative_path);
553            self.copy_single_file(&src, &dest, &relative_path.to_string_lossy())
554                .await?;
555        }
556        Ok(())
557    }
558
559    // Copy a single file from `src` to `dest`, creating parent directories.
560    async fn copy_single_file(&self, src: &Path, dest: &Path, guest_dest: &str) -> Result<()> {
561        // Sanity checks: src is in app_root...
562        src.strip_prefix(&self.app_root)?;
563        // ...and dest is in the Copy root.
564        if let FilesMountStrategy::Copy(files_mount_root) = &self.files_mount_strategy {
565            dest.strip_prefix(files_mount_root)?;
566        } else {
567            unreachable!();
568        }
569
570        let _loading_permit = self.file_loading_permits.acquire().await?;
571        let dest_parent = parent_dir(dest)?;
572        crate::fs::create_dir_all(&dest_parent)
573            .await
574            .with_context(|| {
575                format!(
576                    "Failed to create parent directory {}",
577                    quoted_path(&dest_parent)
578                )
579            })?;
580        crate::fs::copy(src, dest)
581            .await
582            .or_else(|e| Self::failed_to_copy_single_file_error(src, dest, guest_dest, e))?;
583        tracing::debug!("Copied {src:?} to {dest:?}");
584        Ok(())
585    }
586
587    fn failed_to_copy_single_file_error<T>(
588        src: &Path,
589        dest: &Path,
590        guest_dest: &str,
591        e: anyhow::Error,
592    ) -> anyhow::Result<T> {
593        let src_text = quoted_path(src);
594        let dest_text = quoted_path(dest);
595        let base_msg = format!("Failed to copy {src_text} to working path {dest_text}");
596
597        if let Some(io_error) = e.downcast_ref::<std::io::Error>()
598            && (Self::is_directory_like(guest_dest)
599                || io_error.kind() == std::io::ErrorKind::NotFound)
600        {
601            return Err(anyhow::anyhow!(
602                r#""{guest_dest}" is not a valid destination file name"#
603            ))
604            .context(base_msg);
605        }
606
607        Err(e).with_context(|| format!("{base_msg} (for destination path \"{guest_dest}\")"))
608    }
609
610    /// Does a guest path appear to be a directory name, e.g. "/" or ".."? This is for guest
611    /// paths *only* and does not consider Windows separators.
612    fn is_directory_like(guest_path: &str) -> bool {
613        guest_path.ends_with('/') || guest_path.ends_with('.') || guest_path.ends_with("..")
614    }
615
616    // Resolve the given direct mount directory, checking that it is valid for
617    // direct mounting and returning its canonicalized source path.
618    async fn resolve_direct_mount(&self, mount: &WasiFilesMount) -> Result<ContentPath> {
619        let (src, dest) = match mount {
620            WasiFilesMount::Pattern(pattern) => (pattern, pattern),
621            WasiFilesMount::Placement {
622                source,
623                destination,
624            } => (source, destination),
625        };
626        let path = self.app_root.join(src);
627        if !path.is_dir() {
628            bail!(
629                "Only directory mounts are supported with `--direct-mounts`; {src:?} is not a directory."
630            );
631        }
632        Ok(ContentPath {
633            content: file_content_ref(src)?,
634            path: dest.into(),
635        })
636    }
637
638    fn profile(&self) -> Option<&str> {
639        self.profile.as_deref()
640    }
641}
642
643fn explain_file_mount_source_error(e: anyhow::Error, src: &Path) -> anyhow::Error {
644    if let Some(io_error) = e.downcast_ref::<std::io::Error>()
645        && io_error.kind() == std::io::ErrorKind::NotFound
646    {
647        return anyhow::anyhow!("File or directory {} does not exist", quoted_path(src));
648    }
649    e.context(format!("invalid file mount source {}", quoted_path(src)))
650}
651
652#[cfg(feature = "async-io")]
653async fn verified_download(
654    url: &str,
655    digest: &str,
656    dest: &Path,
657    convention: crate::http::DestinationConvention,
658) -> Result<()> {
659    crate::http::verified_download(url, digest, dest, convention)
660        .await
661        .with_context(|| format!("Error fetching source URL {url:?}"))
662}
663
664#[cfg(not(feature = "async-io"))]
665async fn verified_download(
666    _url: &str,
667    _digest: &str,
668    _dest: &Path,
669    _convention: crate::http::DestinationConvention,
670) -> Result<()> {
671    panic!("async-io feature is required for downloading Wasm sources")
672}
673
674fn safe_canonicalize(path: &Path) -> std::io::Result<PathBuf> {
675    use path_absolutize::Absolutize;
676    Ok(path.absolutize()?.into_owned())
677}
678
679fn locked_metadata(
680    details: v2::AppDetails,
681    trigger_types: impl Iterator<Item = String>,
682) -> Result<ValuesMap> {
683    let mut builder = ValuesMapBuilder::new();
684    builder
685        .string("name", details.name)
686        .string("version", details.version)
687        .string("description", details.description)
688        .string_array("authors", details.authors)
689        .serializable("triggers", &details.trigger_global_configs)?;
690
691    // Duplicate single-trigger global options into "trigger" with "type"
692    // key to maintain backward compatibility for a while.
693    let types = trigger_types.collect::<Vec<_>>();
694    if types.len() == 1 {
695        let trigger_type = types.into_iter().next().unwrap();
696        let mut single_trigger = details
697            .trigger_global_configs
698            .get(&trigger_type)
699            .cloned()
700            .unwrap_or_default();
701        single_trigger.insert("type".into(), trigger_type.into());
702        builder.serializable("trigger", single_trigger).unwrap();
703    }
704
705    Ok(builder.build())
706}
707
708fn locked_variable(variable: v2::Variable) -> Result<locked::Variable> {
709    ensure!(
710        variable.required ^ variable.default.is_some(),
711        "must be `required` OR have a `default`"
712    );
713    Ok(locked::Variable {
714        description: variable.description,
715        default: variable.default.clone(),
716        secret: variable.secret,
717    })
718}
719
720#[derive(Debug)]
721/// Handles loading of component Wasm from different sources.
722pub struct WasmLoader {
723    app_root: PathBuf,
724    cache: Cache,
725    file_loading_permits: std::sync::Arc<Semaphore>,
726}
727
728impl WasmLoader {
729    /// Create a new instance of WasmLoader.
730    pub async fn new(
731        app_root: PathBuf,
732        cache_root: Option<PathBuf>,
733        file_loading_permits: Option<std::sync::Arc<Semaphore>>,
734    ) -> Result<Self> {
735        let file_loading_permits = file_loading_permits.unwrap_or_else(|| {
736            std::sync::Arc::new(Semaphore::new(crate::MAX_FILE_LOADING_CONCURRENCY))
737        });
738        Ok(Self {
739            app_root,
740            cache: Cache::new(cache_root).await?,
741            file_loading_permits,
742        })
743    }
744
745    /// Load a Wasm source from the given ComponentSource and return a path
746    /// to a file location from where it can be read.
747    pub async fn load_component_source(
748        &self,
749        component_id: &str,
750        source: &v2::ComponentSource,
751    ) -> Result<PathBuf> {
752        let content = match source {
753            v2::ComponentSource::Local(path) => self.app_root.join(path),
754            v2::ComponentSource::Remote { url, digest } => {
755                self.load_http_source(url, digest).await?
756            }
757            v2::ComponentSource::Registry {
758                registry,
759                package,
760                version,
761            } => {
762                let version = semver::Version::parse(version).with_context(|| format!("Component {component_id} specifies an invalid semantic version ({version:?}) for its package version"))?;
763                let version_req = format!("={version}").parse().expect("version");
764
765                self.load_registry_source(registry.as_ref(), package, &version_req)
766                    .await?
767            }
768        };
769        Ok(content)
770    }
771
772    // Load a Wasm source from the given HTTP ContentRef source URL and
773    // return a ContentRef an absolute path to the local copy.
774    async fn load_http_source(&self, url: &str, digest: &str) -> Result<PathBuf> {
775        ensure!(
776            digest.starts_with("sha256:"),
777            "invalid `digest` {digest:?}; must start with 'sha256:'"
778        );
779        let path = if let Ok(cached_path) = self.cache.wasm_file(digest) {
780            cached_path
781        } else {
782            let _loading_permit = self.file_loading_permits.acquire().await?;
783
784            self.cache.ensure_dirs().await?;
785            let dest = self.cache.wasm_path(digest);
786            verified_download(
787                url,
788                digest,
789                &dest,
790                crate::http::DestinationConvention::ContentIndexed,
791            )
792            .await
793            .with_context(|| format!("Error fetching source URL {url:?}"))?;
794            dest
795        };
796        Ok(path)
797    }
798
799    async fn load_registry_source(
800        &self,
801        registry: Option<&wasm_pkg_client::Registry>,
802        package: &wasm_pkg_client::PackageRef,
803        version: &semver::VersionReq,
804    ) -> Result<PathBuf> {
805        let mut client_config = wasm_pkg_client::Config::global_defaults().await?;
806
807        if let Some(registry) = registry.cloned() {
808            let mapping = wasm_pkg_client::RegistryMapping::Registry(registry);
809            client_config.set_package_registry_override(package.clone(), mapping);
810        }
811        let pkg_loader = wasm_pkg_client::Client::new(client_config);
812
813        let mut releases = pkg_loader.list_all_versions(package).await.map_err(|e| {
814            if matches!(e, wasm_pkg_client::Error::NoRegistryForNamespace(_)) && registry.is_none() {
815                anyhow!("No default registry specified for wasm-pkg-loader. Create a default config, or set `registry` for package {package:?}")
816            } else {
817                e.into()
818            }
819        })?;
820
821        releases.sort();
822
823        let release_version = releases
824            .iter()
825            .rev()
826            .find(|release| version.matches(&release.version) && !release.yanked)
827            .with_context(|| format!("No matching version found for {package} {version}",))?;
828
829        let release = pkg_loader
830            .get_release(package, &release_version.version)
831            .await?;
832
833        let digest = match &release.content_digest {
834            wasm_pkg_client::ContentDigest::Sha256 { hex } => format!("sha256:{hex}"),
835        };
836
837        let path = if let Ok(cached_path) = self.cache.wasm_file(&digest) {
838            cached_path
839        } else {
840            let mut stm = pkg_loader.stream_content(package, &release).await?;
841
842            self.cache.ensure_dirs().await?;
843            let dest = self.cache.wasm_path(&digest);
844
845            let mut file = tokio::fs::File::create(&dest).await?;
846            while let Some(block) = stm.next().await {
847                let bytes = block.context("Failed to get content from registry")?;
848                file.write_all(&bytes)
849                    .await
850                    .context("Failed to save registry content to cache")?;
851            }
852
853            dest
854        };
855
856        Ok(path)
857    }
858
859    /// Loads a dependency and returns a fully resolved locked component dependency.
860    pub async fn load_component_dependency(
861        &self,
862        dependency_name: &DependencyName,
863        dependency: &v2::ComponentDependency,
864    ) -> Result<locked::LockedComponentDependency> {
865        let inherit = locked_inherit(dependency);
866
867        let (content, export) = self
868            .load_dependency_content(dependency_name, dependency)
869            .await?;
870
871        Ok(locked::LockedComponentDependency {
872            source: locked::LockedComponentSource {
873                content_type: "application/wasm".into(),
874                content: file_content_ref(content)?,
875            },
876            export,
877            inherit,
878        })
879    }
880
881    /// Loads a dependency and returns a fully resolved locked component dependency.
882    pub async fn load_trigger_dependency(
883        &self,
884        dependency: &v2::TriggerDependency,
885    ) -> Result<locked::LockedComponentDependency> {
886        let as_component_dep = match dependency.clone() {
887            v2::TriggerDependency::Package {
888                version,
889                registry,
890                package,
891                inherit_configuration,
892            } => v2::ComponentDependency::Package {
893                version,
894                registry,
895                package: Some(package),
896                export: None,
897                inherit_configuration,
898            },
899            v2::TriggerDependency::Local {
900                path,
901                inherit_configuration,
902            } => v2::ComponentDependency::Local {
903                path,
904                export: None,
905                inherit_configuration,
906            },
907            v2::TriggerDependency::HTTP {
908                url,
909                digest,
910                inherit_configuration,
911            } => v2::ComponentDependency::HTTP {
912                url,
913                digest,
914                export: None,
915                inherit_configuration,
916            },
917            v2::TriggerDependency::AppComponent {
918                component,
919                inherit_configuration,
920            } => v2::ComponentDependency::AppComponent {
921                component,
922                export: None,
923                inherit_configuration,
924            },
925        };
926
927        let inherit = locked_inherit(&as_component_dep);
928
929        let fake_dep_name =
930            DependencyName::Plain(KebabId::try_from("to-do-fix-fix-fix".to_string()).unwrap());
931
932        let (content, export) = self
933            .load_dependency_content(&fake_dep_name, &as_component_dep)
934            .await?;
935
936        Ok(locked::LockedComponentDependency {
937            source: locked::LockedComponentSource {
938                content_type: "application/wasm".into(),
939                content: file_content_ref(content)?,
940            },
941            export,
942            inherit,
943        })
944    }
945
946    /// Loads the content path and export for a dependency.
947    pub async fn load_dependency_content(
948        &self,
949        dependency_name: &DependencyName,
950        dependency: &v2::ComponentDependency,
951    ) -> Result<(PathBuf, Option<String>)> {
952        match dependency.clone() {
953            v2::ComponentDependency::Version(version) => {
954                let version = semver::VersionReq::parse(&version).with_context(|| format!("Component dependency {dependency_name:?} specifies an invalid semantic version requirement ({version:?}) for its package version"))?;
955
956                // This `unwrap()` should be OK because we've already validated
957                // this form of dependency requires a package name, i.e. the
958                // dependency name is not a kebab id.
959                let package = dependency_name.package().unwrap();
960
961                let content = self.load_registry_source(None, package, &version).await?;
962                Ok((content, None))
963            }
964            v2::ComponentDependency::Package {
965                version,
966                registry,
967                package,
968                export,
969                ..
970            } => {
971                let version = semver::VersionReq::parse(&version).with_context(|| format!("Component dependency {dependency_name:?} specifies an invalid semantic version requirement ({version:?}) for its package version"))?;
972
973                let package = match package {
974                    Some(package) => {
975                        package.parse().with_context(|| format!("Component dependency {dependency_name:?} specifies an invalid package name ({package:?})"))?
976                    }
977                    None => {
978                        // This `unwrap()` should be OK because we've already validated
979                        // this form of dependency requires a package name, i.e. the
980                        // dependency name is not a kebab id.
981                        dependency_name
982                            .package()
983                            .cloned()
984                            .unwrap()
985                    }
986                };
987
988                let registry = match registry {
989                    Some(registry) => {
990                        registry
991                            .parse()
992                            .map(Some)
993                            .with_context(|| format!("Component dependency {dependency_name:?} specifies an invalid registry name ({registry:?})"))?
994                    }
995                    None => None,
996                };
997
998                let content = self
999                    .load_registry_source(registry.as_ref(), &package, &version)
1000                    .await?;
1001                Ok((content, export))
1002            }
1003            v2::ComponentDependency::Local { path, export, .. } => {
1004                let content = self.app_root.join(path);
1005                Ok((content, export))
1006            }
1007            v2::ComponentDependency::HTTP {
1008                url,
1009                digest,
1010                export,
1011                ..
1012            } => {
1013                let content = self.load_http_source(&url, &digest).await?;
1014                Ok((content, export))
1015            }
1016            v2::ComponentDependency::AppComponent { .. } => {
1017                panic!("Internal error: component ID dependency was not resolved to a source");
1018            }
1019        }
1020    }
1021}
1022
1023fn locked_inherit(dependency: &v2::ComponentDependency) -> locked::InheritConfiguration {
1024    match dependency.inherit_configuration() {
1025        Some(v2::InheritConfiguration::All(true)) => locked::InheritConfiguration::All,
1026        Some(v2::InheritConfiguration::Some(keys)) => {
1027            locked::InheritConfiguration::Some(keys.clone())
1028        }
1029        Some(v2::InheritConfiguration::All(false)) | None => {
1030            locked::InheritConfiguration::Some(vec![])
1031        }
1032    }
1033}
1034
1035fn looks_like_glob_pattern(s: impl AsRef<str>) -> bool {
1036    let s = s.as_ref();
1037    glob::Pattern::escape(s) != s
1038}
1039
1040fn file_content_ref(path: impl AsRef<Path>) -> Result<ContentRef> {
1041    Ok(ContentRef {
1042        source: Some(file_url(path)?),
1043        ..Default::default()
1044    })
1045}
1046
1047fn file_url(path: impl AsRef<Path>) -> Result<String> {
1048    let path = path.as_ref();
1049    let abs_path = safe_canonicalize(path)
1050        .with_context(|| format!("Couldn't resolve `{}`", path.display()))?;
1051    Ok(Url::from_file_path(abs_path).unwrap().to_string())
1052}
1053
1054/// Determines if a component requires the host to support local
1055/// service chaining.
1056pub fn requires_service_chaining(component: &spin_manifest::schema::v2::Component) -> bool {
1057    component
1058        .normalized_allowed_outbound_hosts()
1059        .unwrap_or_default()
1060        .iter()
1061        .any(|h| is_chaining_host(h))
1062}
1063
1064fn is_chaining_host(pattern: &str) -> bool {
1065    AllowedHostConfig::parse(pattern).is_ok_and(|config| config.is_for_service_chaining())
1066}
1067
1068const SLOTH_WARNING_DELAY_MILLIS: u64 = 1250;
1069
1070fn warn_if_component_load_slothful() -> sloth::SlothGuard {
1071    let message = "Loading Wasm components is taking a few seconds...";
1072    sloth::warn_if_slothful(SLOTH_WARNING_DELAY_MILLIS, format!("{message}\n"))
1073}
1074
1075#[cfg(test)]
1076mod test {
1077    use super::*;
1078
1079    async fn load_test_case(
1080        testcase_dir: &str,
1081        manifest_file: &str,
1082    ) -> anyhow::Result<(tempfile::TempDir, LockedApp)> {
1083        let app_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1084            .join("tests")
1085            .join(testcase_dir);
1086        let wd = tempfile::tempdir()?;
1087        let loader = LocalLoader::new(
1088            &app_root,
1089            FilesMountStrategy::Copy(wd.path().to_owned()),
1090            None,
1091            None,
1092        )
1093        .await?;
1094        let locked_app = loader.load_file(app_root.join(manifest_file)).await;
1095        locked_app.map(|locked| (wd, locked))
1096    }
1097
1098    #[tokio::test]
1099    async fn bad_destination_filename_is_explained() -> anyhow::Result<()> {
1100        let err = load_test_case("file-errors", "bad.toml")
1101            .await
1102            .expect_err("loader should not have succeeded");
1103        let err_ctx = format!("{err:#}");
1104        assert!(
1105            err_ctx.contains(r#""/" is not a valid destination file name"#),
1106            "expected error to show destination file name but got {err_ctx}",
1107        );
1108        Ok(())
1109    }
1110
1111    fn trigger_by_route<'a>(locked: &'a LockedApp, route: &str) -> &'a LockedTrigger {
1112        fn route_of(trigger: &LockedTrigger) -> &str {
1113            trigger
1114                .trigger_config
1115                .get("route")
1116                .and_then(|v| v.as_str())
1117                .unwrap()
1118        }
1119        locked
1120            .triggers
1121            .iter()
1122            .find(|t| route_of(t) == route)
1123            .unwrap()
1124    }
1125
1126    fn component_for_route<'a>(locked: &'a LockedApp, route: &str) -> &'a LockedComponent {
1127        let component_id = component_id(trigger_by_route(locked, route));
1128        locked
1129            .components
1130            .iter()
1131            .find(|c| c.id == component_id)
1132            .unwrap()
1133    }
1134
1135    fn component_id(trigger: &LockedTrigger) -> &str {
1136        trigger
1137            .trigger_config
1138            .get("component")
1139            .and_then(|v| v.as_str())
1140            .unwrap()
1141    }
1142
1143    fn component_trigger_deps_for_route<'a>(
1144        locked: &'a LockedApp,
1145        route: &str,
1146        key: &str,
1147    ) -> &'a Vec<LockedComponentDependency> {
1148        let component = component_for_route(locked, route);
1149        component
1150            .trigger_dependencies
1151            .get(key)
1152            .expect("should have had trigger deps for key")
1153    }
1154
1155    #[tokio::test]
1156    async fn unenriched_lockfile_is_unchanged() {
1157        let (_wd, locked_app) = load_test_case("extra-components", "vanilla.toml")
1158            .await
1159            .unwrap();
1160        assert_eq!(3, locked_app.triggers.len());
1161        assert_eq!(2, locked_app.components.len());
1162    }
1163
1164    #[tokio::test]
1165    async fn enriched_lockfile_only_one_trigger_per_component_no_changes() {
1166        let (_wd, locked_app) = load_test_case("extra-components", "inoffensive.toml")
1167            .await
1168            .unwrap();
1169        assert_eq!(2, locked_app.triggers.len());
1170        assert_eq!("a", component_id(trigger_by_route(&locked_app, "/a")));
1171        assert_eq!("b", component_id(trigger_by_route(&locked_app, "/b")));
1172        assert_eq!(5, locked_app.components.len());
1173    }
1174
1175    #[tokio::test]
1176    async fn enriched_lockfile_multiple_enriched_triggers_per_component_get_split() {
1177        let (_wd, locked_app) = load_test_case("extra-components", "three-to-one.toml")
1178            .await
1179            .unwrap();
1180        assert_eq!(4, locked_app.triggers.len());
1181        // Splitting should result in triggers pointing to different IDs, but the same primary source
1182        assert_ne!("a", component_id(trigger_by_route(&locked_app, "/a1")));
1183        assert!(
1184            component_for_route(&locked_app, "/a1")
1185                .source
1186                .content
1187                .source
1188                .as_ref()
1189                .unwrap()
1190                .ends_with("/a.dummy.wasm.txt")
1191        );
1192        assert_ne!("a", component_id(trigger_by_route(&locked_app, "/a2")));
1193        assert!(
1194            component_for_route(&locked_app, "/a3")
1195                .source
1196                .content
1197                .source
1198                .as_ref()
1199                .unwrap()
1200                .ends_with("/a.dummy.wasm.txt")
1201        );
1202        assert_ne!("a", component_id(trigger_by_route(&locked_app, "/a3")));
1203        assert!(
1204            component_for_route(&locked_app, "/a3")
1205                .source
1206                .content
1207                .source
1208                .as_ref()
1209                .unwrap()
1210                .ends_with("/a.dummy.wasm.txt")
1211        );
1212        // Triggers that don't need splitting should be unaffected
1213        assert_eq!("b", component_id(trigger_by_route(&locked_app, "/b")));
1214        // There should be new components inserted for the split
1215        assert_eq!(8, locked_app.components.len());
1216    }
1217
1218    // Content refs have `file:` URIs with absolute paths. This is great for
1219    // definiteness but not so convenient for testing!
1220    fn content_ref_to_file_name(content_ref: &ContentRef) -> String {
1221        let url = content_ref.source.as_ref().unwrap();
1222        let url = Url::parse(url).unwrap();
1223        let file_path = PathBuf::from(url.path());
1224        file_path.file_name().unwrap().to_string_lossy().to_string()
1225    }
1226
1227    #[tokio::test]
1228    async fn enriched_lockfile_captures_composition_graph_in_split_component() {
1229        let (_wd, locked_app) = load_test_case("extra-components", "three-to-one.toml")
1230            .await
1231            .unwrap();
1232        assert_eq!(4, locked_app.triggers.len());
1233
1234        let a1_mw = component_trigger_deps_for_route(&locked_app, "/a1", "middleware");
1235        assert_eq!(2, a1_mw.len());
1236        assert_eq!(
1237            "a.dummy.wasm.txt",
1238            content_ref_to_file_name(&a1_mw[0].source.content)
1239        );
1240        assert_eq!(
1241            "b.dummy.wasm.txt",
1242            content_ref_to_file_name(&a1_mw[1].source.content)
1243        );
1244
1245        let a2_mw = component_trigger_deps_for_route(&locked_app, "/a2", "middleware");
1246        assert_eq!(2, a2_mw.len());
1247        assert_eq!(
1248            "b.dummy.wasm.txt",
1249            content_ref_to_file_name(&a2_mw[0].source.content)
1250        );
1251        assert_eq!(
1252            "c.dummy.wasm.txt",
1253            content_ref_to_file_name(&a2_mw[1].source.content)
1254        );
1255
1256        let a3_mw = component_trigger_deps_for_route(&locked_app, "/a3", "middleware");
1257        assert_eq!(3, a3_mw.len());
1258        assert_eq!(
1259            "c.dummy.wasm.txt",
1260            content_ref_to_file_name(&a3_mw[0].source.content)
1261        );
1262        assert_eq!(
1263            "b.dummy.wasm.txt",
1264            content_ref_to_file_name(&a3_mw[1].source.content)
1265        );
1266        assert_eq!(
1267            "a.dummy.wasm.txt",
1268            content_ref_to_file_name(&a3_mw[2].source.content)
1269        );
1270
1271        // Unsplit things should still get the shunt
1272        let b_mw = component_trigger_deps_for_route(&locked_app, "/b", "middleware");
1273        assert_eq!(2, b_mw.len());
1274        assert_eq!(
1275            "a.dummy.wasm.txt",
1276            content_ref_to_file_name(&b_mw[0].source.content)
1277        );
1278        assert_eq!(
1279            "c.dummy.wasm.txt",
1280            content_ref_to_file_name(&b_mw[1].source.content)
1281        );
1282    }
1283
1284    #[tokio::test]
1285    async fn deps_moved_off_trigger() {
1286        let (_wd, locked_app) = load_test_case("extra-components", "three-to-one.toml")
1287            .await
1288            .unwrap();
1289        assert_eq!(4, locked_app.triggers.len());
1290
1291        for t in &locked_app.triggers {
1292            assert!(t.trigger_dependencies.is_empty());
1293        }
1294    }
1295}