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