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