Skip to main content

spin_oci/
loader.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result, anyhow, ensure};
4use oci_client::Reference;
5use reqwest::Url;
6use spin_common::ui::quoted_path;
7use spin_loader::cache::Cache;
8use spin_locked_app::locked::{ContentPath, ContentRef, LockedApp, LockedComponent};
9
10use crate::client::{locked_app_descriptor, verify_digest};
11use crate::{Client, ORIGIN_URL_SCHEME};
12
13/// The directory, in the working directory of a loaded app or in a prepared
14/// app, that holds each component's files in `<component id>/`.
15pub(crate) const ASSETS_DIR: &str = "assets";
16
17/// OciLoader loads an OCI app in preparation for running with Spin.
18pub struct OciLoader {
19    working_dir: PathBuf,
20}
21
22/// The artifact loaded by the `OciLoader`
23pub enum ExecutableArtifact {
24    /// The OCI reference contained a Spin application
25    Application(LockedApp),
26    /// The OCI reference contained a Wasm package
27    Package(PathBuf),
28}
29
30impl OciLoader {
31    /// Creates a new OciLoader which builds temporary mount directory(s) in
32    /// the given working_dir.
33    pub fn new(working_dir: impl Into<PathBuf>) -> Self {
34        let working_dir = working_dir.into();
35        Self { working_dir }
36    }
37
38    /// Pulls and loads an OCI Artifact and returns a LockedApp with the given OCI client and reference
39    pub async fn load_app(
40        &self,
41        client: &mut Client,
42        reference: &str,
43    ) -> Result<ExecutableArtifact> {
44        // Fetch app
45        let (manifest, digest) = client.pull_with_digest(reference).await.with_context(|| {
46            format!("cannot pull Spin application from registry reference {reference:?}")
47        })?;
48        let lockfile_path = cached_lockfile_path(client, reference, &digest).await?;
49        self.load_from_cache(manifest, lockfile_path, reference, &client.cache)
50            .await
51    }
52
53    /// Loads an OCI Artifact from the given cache and returns a LockedApp with the given reference
54    pub async fn load_from_cache(
55        &self,
56        manifest: oci_client::manifest::OciImageManifest,
57        lockfile_path: PathBuf,
58        reference: &str,
59        cache: &Cache,
60    ) -> std::result::Result<ExecutableArtifact, anyhow::Error> {
61        match read_locked_app(&manifest, &lockfile_path, reference).await? {
62            Some(mut locked_app) => {
63                for component in &mut locked_app.components {
64                    self.resolve_component_content_refs(component, cache)
65                        .await
66                        .with_context(|| {
67                            format!("failed to resolve content for component {:?}", component.id)
68                        })?;
69                }
70                Ok(ExecutableArtifact::Application(locked_app))
71            }
72            None => {
73                if manifest.layers.len() != 1 {
74                    anyhow::bail!(
75                        "expected single layer in OCI package, found {} layers",
76                        manifest.layers.len()
77                    );
78                }
79                let layer = &manifest.layers[0]; // guaranteed safe by previous check
80                let wasm_path = cache.wasm_path(&layer.digest);
81                Ok(ExecutableArtifact::Package(wasm_path))
82            }
83        }
84    }
85
86    /// Resolves all digest references in the component (including source, dependencies,
87    /// and asset files). Wasm sources are replaced with cache paths. Asset files are
88    /// collected to a mount path, and the component `files` amended to reflect that.
89    ///
90    /// This function assumes that:
91    ///
92    /// 1. All resolvable items have digest or inline sources.
93    /// 2. All digest-addressed content is already in the cache.
94    ///
95    /// If either of these is not true, the function errors.
96    pub async fn resolve_component_content_refs(
97        &self,
98        component: &mut LockedComponent,
99        cache: &Cache,
100    ) -> Result<()> {
101        ensure!(
102            is_safe_to_join(&component.id),
103            "invalid component id {}",
104            component.id
105        );
106
107        // Update wasm content paths
108        for content in wasm_content_refs_mut(component) {
109            let wasm_path = cache.wasm_file(content_digest(content)?)?;
110            *content = content_ref(wasm_path)?;
111        }
112
113        if !component.files.is_empty() {
114            let mount_dir = write_component_files(
115                &component.id,
116                &component.files,
117                cache,
118                &self.working_dir.join(ASSETS_DIR),
119            )
120            .await?;
121            component.files = vec![ContentPath {
122                content: content_ref(mount_dir)?,
123                path: "/".into(),
124            }]
125        }
126
127        Ok(())
128    }
129}
130
131/// Returns the path of the locked app that a pull of `reference` cached for
132/// `digest`, the manifest digest the reference resolved to.
133pub(crate) async fn cached_lockfile_path(
134    client: &Client,
135    reference: &str,
136    digest: &str,
137) -> Result<PathBuf> {
138    // The location for the reference as given may hold another artifact if
139    // the reference is a tag.
140    let parsed = Reference::try_from(reference).context("invalid reference")?;
141    let pinned = Reference::with_digest(
142        parsed.registry().to_owned(),
143        parsed.repository().to_owned(),
144        digest.to_owned(),
145    );
146    client
147        .lockfile_path(pinned.whole())
148        .await
149        .context("cannot get path to spin.lock")
150}
151
152/// Reads the locked app of an OCI artifact from `lockfile_path`, after
153/// checking it against the digest the manifest records, and sets its origin
154/// to `reference`. Returns `None` if the artifact is a Wasm package rather
155/// than a Spin application.
156pub(crate) async fn read_locked_app(
157    manifest: &oci_client::manifest::OciImageManifest,
158    lockfile_path: &Path,
159    reference: &str,
160) -> Result<Option<LockedApp>> {
161    let locked_content = tokio::fs::read(lockfile_path)
162        .await
163        .with_context(|| format!("failed to read from {}", quoted_path(lockfile_path)))?;
164    let locked_app_descriptor = locked_app_descriptor(manifest)?;
165    verify_digest(&locked_content, &locked_app_descriptor.digest).with_context(|| {
166        format!(
167            "{} is not the locked app of {reference:?}",
168            quoted_path(lockfile_path)
169        )
170    })?;
171    let locked_json: serde_json::Value = serde_json::from_slice(&locked_content)
172        .with_context(|| format!("OCI config {} is not JSON", quoted_path(lockfile_path)))?;
173    if locked_json.get("spin_lock_version").is_none() {
174        return Ok(None);
175    }
176
177    let mut locked_app = LockedApp::from_json(&locked_content).with_context(|| {
178        format!(
179            "failed to decode locked app from {}",
180            quoted_path(lockfile_path)
181        )
182    })?;
183
184    // Update origin metadata
185    let resolved_reference = Reference::try_from(reference).context("invalid reference")?;
186    let origin_uri = format!("{ORIGIN_URL_SCHEME}:{resolved_reference}");
187    locked_app
188        .metadata
189        .insert("origin".to_string(), origin_uri.into());
190    Ok(Some(locked_app))
191}
192
193/// Returns each Wasm content reference of the component: its own source,
194/// and those of its dependencies and trigger dependencies.
195///
196/// Applications pushed without composition carry their trigger dependencies
197/// (e.g. HTTP middleware) as layers of their own, so those are included.
198pub(crate) fn wasm_content_refs_mut(
199    component: &mut LockedComponent,
200) -> impl Iterator<Item = &mut ContentRef> {
201    std::iter::once(&mut component.source.content)
202        .chain(
203            component
204                .dependencies
205                .values_mut()
206                .map(|dep| &mut dep.source.content),
207        )
208        .chain(
209            component
210                .trigger_dependencies
211                .values_mut()
212                .flatten()
213                .map(|dep| &mut dep.source.content),
214        )
215}
216
217/// Writes a component's files, from their inline content or the cache, to
218/// `assets_dir/<component id>`, laid out as the guest sees them, and returns
219/// that directory.
220///
221/// Every file must have inline content or a digest whose content is in the
222/// cache. Nothing is created when `files` is empty.
223pub(crate) async fn write_component_files(
224    component_id: &str,
225    files: &[ContentPath],
226    cache: &Cache,
227    assets_dir: &Path,
228) -> Result<PathBuf> {
229    // Callers may check the id too; this function joins it to a path, so it
230    // checks for itself.
231    ensure!(
232        is_safe_to_join(component_id),
233        "invalid component id {component_id}"
234    );
235    let mount_dir = assets_dir.join(component_id);
236    for file in files {
237        ensure!(is_safe_to_join(&file.path), "invalid file mount {file:?}");
238        let mount_path = mount_dir.join(&file.path);
239
240        // Create parent directory
241        let mount_parent = mount_path
242            .parent()
243            .with_context(|| format!("invalid mount path {mount_path:?}"))?;
244        tokio::fs::create_dir_all(mount_parent)
245            .await
246            .with_context(|| format!("failed to create temporary mount path {mount_path:?}"))?;
247
248        if let Some(content_bytes) = file.content.inline.as_deref() {
249            // Write inline content to disk
250            tokio::fs::write(&mount_path, content_bytes)
251                .await
252                .with_context(|| format!("failed to write inline content to {mount_path:?}"))?;
253        } else {
254            // Copy content
255            let digest = content_digest(&file.content)?;
256            let content_path = cache.data_file(digest)?;
257            // TODO: parallelize
258            tokio::fs::copy(&content_path, &mount_path)
259                .await
260                .with_context(|| {
261                    format!(
262                        "failed to copy {}->{mount_path:?}",
263                        quoted_path(&content_path)
264                    )
265                })?;
266        }
267    }
268    Ok(mount_dir)
269}
270
271pub(crate) fn content_digest(content_ref: &ContentRef) -> Result<&str> {
272    content_ref
273        .digest
274        .as_deref()
275        .with_context(|| format!("content missing expected digest: {content_ref:?}"))
276}
277
278fn content_ref(path: impl AsRef<Path>) -> Result<ContentRef> {
279    let path = std::fs::canonicalize(path)?;
280    let url = Url::from_file_path(path).map_err(|_| anyhow!("couldn't build file URL"))?;
281    Ok(ContentRef {
282        source: Some(url.to_string()),
283        ..Default::default()
284    })
285}
286
287pub(crate) fn is_safe_to_join(path: impl AsRef<Path>) -> bool {
288    // This could be loosened, but currently should always be true
289    path.as_ref()
290        .components()
291        .all(|c| matches!(c, std::path::Component::Normal(_)))
292}
293
294#[cfg(test)]
295mod test {
296    use super::*;
297
298    fn locked_app_json(component: &str) -> Vec<u8> {
299        serde_json::to_vec(&serde_json::json!({
300            "spin_lock_version": 1,
301            "metadata": { "name": component },
302            "triggers": [],
303            "components": [],
304        }))
305        .unwrap()
306    }
307
308    fn manifest_with_locked_app(locked_app: &[u8]) -> oci_client::manifest::OciImageManifest {
309        let digest = format!(
310            "sha256:{}",
311            spin_common::sha256::hex_digest_from_bytes(locked_app)
312        );
313        oci_client::manifest::OciImageManifest {
314            layers: vec![oci_client::manifest::OciDescriptor {
315                media_type: crate::client::SPIN_APPLICATION_MEDIA_TYPE.to_owned(),
316                digest,
317                ..Default::default()
318            }],
319            ..Default::default()
320        }
321    }
322
323    async fn mount_test_component_assets(
324        component_id: &str,
325        working_dir: &Path,
326        cache_dir: &Path,
327    ) -> anyhow::Result<()> {
328        let loader = OciLoader::new(working_dir);
329        let cache = Cache::new(Some(cache_dir.into())).await?;
330
331        let fake_wasm = vec![1, 2, 3, 4];
332        let fake_wasm_digest = spin_common::sha256::hex_digest_from_bytes(&fake_wasm);
333        cache.write_wasm(&fake_wasm, &fake_wasm_digest).await?;
334
335        let fake_file = "some content".into();
336        let fake_file_digest = spin_common::sha256::hex_digest_from_bytes(&fake_file);
337        cache.write_data(&fake_file, &fake_file_digest).await?;
338
339        let mut component = LockedComponent {
340            id: component_id.into(),
341            metadata: Default::default(),
342            source: spin_locked_app::locked::LockedComponentSource {
343                content_type: "application/wasm".into(),
344                content: ContentRef {
345                    source: None,
346                    inline: Some(fake_wasm),
347                    digest: Some(fake_wasm_digest),
348                },
349            },
350            env: Default::default(),
351            files: vec![ContentPath {
352                content: ContentRef {
353                    source: None,
354                    inline: Some(fake_file),
355                    digest: Some(fake_file_digest),
356                },
357                path: "file.txt".into(),
358            }],
359            config: Default::default(),
360            dependencies: Default::default(),
361            trigger_dependencies: Default::default(),
362            host_requirements: Default::default(),
363        };
364
365        loader
366            .resolve_component_content_refs(&mut component, &cache)
367            .await
368    }
369
370    fn wasm_source(digest: &str) -> serde_json::Value {
371        serde_json::json!({ "content_type": "application/wasm", "digest": digest })
372    }
373
374    fn sha256_digest(bytes: &[u8]) -> String {
375        format!(
376            "sha256:{}",
377            spin_common::sha256::hex_digest_from_bytes(bytes)
378        )
379    }
380
381    #[tokio::test]
382    async fn content_refs_resolve_to_cache_and_assets() -> anyhow::Result<()> {
383        let working_dir = tempfile::tempdir()?;
384        let cache_dir = tempfile::tempdir()?;
385        let cache = Cache::new(Some(cache_dir.path().into())).await?;
386        let loader = OciLoader::new(working_dir.path());
387
388        let mut wasm_digests = vec![];
389        for wasm in [b"main".as_slice(), b"dependency", b"middleware"] {
390            let digest = sha256_digest(wasm);
391            cache.write_wasm(wasm, &digest).await?;
392            wasm_digests.push(digest);
393        }
394        let file = b"some content".to_vec();
395        let file_digest = sha256_digest(&file);
396        cache.write_data(&file, &file_digest).await?;
397        let inline = b"inline content".to_vec();
398
399        let mut component: LockedComponent = serde_json::from_value(serde_json::json!({
400            "id": "comp",
401            "source": wasm_source(&wasm_digests[0]),
402            "dependencies": { "test:dep": { "source": wasm_source(&wasm_digests[1]) } },
403            "trigger_dependencies": {
404                "middleware": [{ "source": wasm_source(&wasm_digests[2]) }]
405            },
406            "files": [{ "digest": file_digest, "path": "dir/file.txt" }],
407        }))?;
408        component.files.push(ContentPath {
409            content: ContentRef {
410                inline: Some(inline.clone()),
411                ..Default::default()
412            },
413            path: "inline.txt".into(),
414        });
415
416        loader
417            .resolve_component_content_refs(&mut component, &cache)
418            .await?;
419
420        // Wasm sources point at the cache, with no digest or inline content.
421        let wasm_contents = [
422            &component.source.content,
423            &component
424                .dependencies
425                .values()
426                .next()
427                .unwrap()
428                .source
429                .content,
430            &component.trigger_dependencies["middleware"][0]
431                .source
432                .content,
433        ];
434        for (content, digest) in wasm_contents.into_iter().zip(&wasm_digests) {
435            let expected = content_ref(cache.wasm_file(digest)?)?;
436            assert_eq!(content.source, expected.source);
437            assert!(content.digest.is_none() && content.inline.is_none());
438        }
439
440        // Files are written beneath assets/<component id> and mounted at `/`.
441        let mount_dir = working_dir.path().join("assets/comp");
442        assert_eq!(component.files.len(), 1);
443        assert_eq!(component.files[0].path, Path::new("/"));
444        assert_eq!(
445            component.files[0].content.source,
446            content_ref(&mount_dir)?.source
447        );
448        assert_eq!(std::fs::read(mount_dir.join("dir/file.txt"))?, file);
449        assert_eq!(std::fs::read(mount_dir.join("inline.txt"))?, inline);
450
451        Ok(())
452    }
453
454    #[tokio::test]
455    async fn file_path_cannot_be_used_to_escape_asset_directory() -> anyhow::Result<()> {
456        let base_dir = tempfile::tempdir()?;
457        let cache_dir = tempfile::tempdir()?;
458        let cache = Cache::new(Some(cache_dir.path().into())).await?;
459        let assets_dir = base_dir.path().join("good/assets");
460
461        let files: Vec<ContentPath> = serde_json::from_value(serde_json::json!([
462            { "inline": "", "path": "../../escaped.txt" },
463        ]))?;
464        write_component_files("comp", &files, &cache, &assets_dir)
465            .await
466            .expect_err("a file path with .. should be rejected");
467        assert!(!base_dir.path().join("good/escaped.txt").exists());
468        assert!(!base_dir.path().join("escaped.txt").exists());
469
470        Ok(())
471    }
472
473    #[tokio::test]
474    async fn locked_app_of_another_artifact_is_rejected() -> anyhow::Result<()> {
475        let working_dir = tempfile::tempdir()?;
476        let cache_dir = tempfile::tempdir()?;
477        let cache = Cache::new(Some(cache_dir.path().into())).await?;
478        let loader = OciLoader::new(working_dir.path());
479        let reference = "example.com/org/app@sha256:0a867093096e0ef01ef749b12b6e7a90e4952eda107f89a676eeedce63a8361f";
480
481        let requested = locked_app_json("requested");
482        let lockfile_path = working_dir.path().join("config.json");
483
484        // The cached locked app belongs to another artifact.
485        std::fs::write(&lockfile_path, locked_app_json("other"))?;
486        let err = loader
487            .load_from_cache(
488                manifest_with_locked_app(&requested),
489                lockfile_path.clone(),
490                reference,
491                &cache,
492            )
493            .await
494            .err()
495            .expect("a locked app that does not match the manifest should be rejected");
496        assert!(
497            format!("{err:#}").contains("is not the locked app of"),
498            "unexpected error: {err:#}"
499        );
500
501        // The cached locked app is the requested one.
502        std::fs::write(&lockfile_path, &requested)?;
503        let artifact = loader
504            .load_from_cache(
505                manifest_with_locked_app(&requested),
506                lockfile_path,
507                reference,
508                &cache,
509            )
510            .await?;
511        assert!(matches!(artifact, ExecutableArtifact::Application(_)));
512
513        Ok(())
514    }
515
516    #[tokio::test]
517    async fn component_id_cannot_be_used_to_escape_asset_directory() -> anyhow::Result<()> {
518        let component_id = "../../crimeburger";
519
520        let base_dir = tempfile::tempdir()?;
521        let cache_dir = tempfile::tempdir()?;
522        let working_dir = base_dir.path().join("good");
523
524        let res = mount_test_component_assets(component_id, &working_dir, cache_dir.path()).await;
525
526        // Validate that the file was not written - checking the error is insufficient
527        // because it could write and then fail.
528        assert!(
529            !base_dir.path().join("crimeburger/file.txt").exists(),
530            "crimeburger file exists when it shouldn't"
531        );
532        res.expect_err("illegal component id should have caused error");
533
534        Ok(())
535    }
536
537    #[tokio::test]
538    async fn legal_component_id_is_accepted() -> anyhow::Result<()> {
539        let component_id = "legal";
540
541        let base_dir = tempfile::tempdir()?;
542        let cache_dir = tempfile::tempdir()?;
543        let working_dir = base_dir.path().join("good");
544
545        mount_test_component_assets(component_id, &working_dir, cache_dir.path()).await?;
546
547        assert!(
548            working_dir.join("assets/legal/file.txt").exists(),
549            "file was not written when it should be"
550        );
551
552        Ok(())
553    }
554}