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/// OciLoader loads an OCI app in preparation for running with Spin.
14pub struct OciLoader {
15    working_dir: PathBuf,
16}
17
18/// The artifact loaded by the `OciLoader`
19pub enum ExecutableArtifact {
20    /// The OCI reference contained a Spin application
21    Application(LockedApp),
22    /// The OCI reference contained a Wasm package
23    Package(PathBuf),
24}
25
26impl OciLoader {
27    /// Creates a new OciLoader which builds temporary mount directory(s) in
28    /// the given working_dir.
29    pub fn new(working_dir: impl Into<PathBuf>) -> Self {
30        let working_dir = working_dir.into();
31        Self { working_dir }
32    }
33
34    /// Pulls and loads an OCI Artifact and returns a LockedApp with the given OCI client and reference
35    pub async fn load_app(
36        &self,
37        client: &mut Client,
38        reference: &str,
39    ) -> Result<ExecutableArtifact> {
40        // Fetch app
41        let (manifest, digest) = client.pull_with_digest(reference).await.with_context(|| {
42            format!("cannot pull Spin application from registry reference {reference:?}")
43        })?;
44
45        // Read the locked app cached for the pulled digest. The location for the
46        // reference as given may hold another artifact if the reference is a tag.
47        let parsed = Reference::try_from(reference).context("invalid reference")?;
48        let pinned = Reference::with_digest(
49            parsed.registry().to_owned(),
50            parsed.repository().to_owned(),
51            digest,
52        );
53        let lockfile_path = client
54            .lockfile_path(pinned.whole())
55            .await
56            .context("cannot get path to spin.lock")?;
57        self.load_from_cache(manifest, lockfile_path, reference, &client.cache)
58            .await
59    }
60
61    /// Loads an OCI Artifact from the given cache and returns a LockedApp with the given reference
62    pub async fn load_from_cache(
63        &self,
64        manifest: oci_client::manifest::OciImageManifest,
65        lockfile_path: PathBuf,
66        reference: &str,
67        cache: &Cache,
68    ) -> std::result::Result<ExecutableArtifact, anyhow::Error> {
69        let locked_content = tokio::fs::read(&lockfile_path)
70            .await
71            .with_context(|| format!("failed to read from {}", quoted_path(&lockfile_path)))?;
72        let locked_app_descriptor = locked_app_descriptor(&manifest)?;
73        verify_digest(&locked_content, &locked_app_descriptor.digest).with_context(|| {
74            format!(
75                "{} is not the locked app of {reference:?}",
76                quoted_path(&lockfile_path)
77            )
78        })?;
79        let locked_json: serde_json::Value = serde_json::from_slice(&locked_content)
80            .with_context(|| format!("OCI config {} is not JSON", quoted_path(&lockfile_path)))?;
81
82        if locked_json.get("spin_lock_version").is_some() {
83            let mut locked_app = LockedApp::from_json(&locked_content).with_context(|| {
84                format!(
85                    "failed to decode locked app from {}",
86                    quoted_path(&lockfile_path)
87                )
88            })?;
89
90            // Update origin metadata
91            let resolved_reference = Reference::try_from(reference).context("invalid reference")?;
92            let origin_uri = format!("{ORIGIN_URL_SCHEME}:{resolved_reference}");
93            locked_app
94                .metadata
95                .insert("origin".to_string(), origin_uri.into());
96
97            for component in &mut locked_app.components {
98                self.resolve_component_content_refs(component, cache)
99                    .await
100                    .with_context(|| {
101                        format!("failed to resolve content for component {:?}", component.id)
102                    })?;
103            }
104            Ok(ExecutableArtifact::Application(locked_app))
105        } else {
106            if manifest.layers.len() != 1 {
107                anyhow::bail!(
108                    "expected single layer in OCI package, found {} layers",
109                    manifest.layers.len()
110                );
111            }
112            let layer = &manifest.layers[0]; // guaranteed safe by previous check
113            let wasm_path = cache.wasm_path(&layer.digest);
114            Ok(ExecutableArtifact::Package(wasm_path))
115        }
116    }
117
118    /// Resolves all digest references in the component (including source, dependencies,
119    /// and asset files). Wasm sources are replaced with cache paths. Asset files are
120    /// collected to a mount path, and the component `files` amended to reflect that.
121    ///
122    /// This function assumes that:
123    ///
124    /// 1. All resolvable items have digest or inline sources.
125    /// 2. All digest-addressed content is already in the cache.
126    ///
127    /// If either of these is not true, the function errors.
128    pub async fn resolve_component_content_refs(
129        &self,
130        component: &mut LockedComponent,
131        cache: &Cache,
132    ) -> Result<()> {
133        ensure!(
134            is_safe_to_join(&component.id),
135            "invalid component id {}",
136            component.id
137        );
138
139        // Update wasm content path
140        let wasm_digest = content_digest(&component.source.content)?;
141        let wasm_path = cache.wasm_file(wasm_digest)?;
142        component.source.content = content_ref(wasm_path)?;
143
144        for dep in &mut component.dependencies.values_mut() {
145            let dep_wasm_digest = content_digest(&dep.source.content)?;
146            let dep_wasm_path = cache.wasm_file(dep_wasm_digest)?;
147            dep.source.content = content_ref(dep_wasm_path)?;
148        }
149
150        // Applications pushed without composition carry their trigger dependencies
151        // (e.g. HTTP middleware) as layers of their own, so those need resolving too.
152        for deps in component.trigger_dependencies.values_mut() {
153            for dep in deps {
154                let dep_wasm_digest = content_digest(&dep.source.content)?;
155                let dep_wasm_path = cache.wasm_file(dep_wasm_digest)?;
156                dep.source.content = content_ref(dep_wasm_path)?;
157            }
158        }
159
160        if !component.files.is_empty() {
161            let mount_dir = self.working_dir.join("assets").join(&component.id);
162            for file in &mut component.files {
163                ensure!(is_safe_to_join(&file.path), "invalid file mount {file:?}");
164                let mount_path = mount_dir.join(&file.path);
165
166                // Create parent directory
167                let mount_parent = mount_path
168                    .parent()
169                    .with_context(|| format!("invalid mount path {mount_path:?}"))?;
170                tokio::fs::create_dir_all(mount_parent)
171                    .await
172                    .with_context(|| {
173                        format!("failed to create temporary mount path {mount_path:?}")
174                    })?;
175
176                if let Some(content_bytes) = file.content.inline.as_deref() {
177                    // Write inline content to disk
178                    tokio::fs::write(&mount_path, content_bytes)
179                        .await
180                        .with_context(|| {
181                            format!("failed to write inline content to {mount_path:?}")
182                        })?;
183                } else {
184                    // Copy content
185                    let digest = content_digest(&file.content)?;
186                    let content_path = cache.data_file(digest)?;
187                    // TODO: parallelize
188                    tokio::fs::copy(&content_path, &mount_path)
189                        .await
190                        .with_context(|| {
191                            format!(
192                                "failed to copy {}->{mount_path:?}",
193                                quoted_path(&content_path)
194                            )
195                        })?;
196                }
197            }
198
199            component.files = vec![ContentPath {
200                content: content_ref(mount_dir)?,
201                path: "/".into(),
202            }]
203        }
204
205        Ok(())
206    }
207}
208
209fn content_digest(content_ref: &ContentRef) -> Result<&str> {
210    content_ref
211        .digest
212        .as_deref()
213        .with_context(|| format!("content missing expected digest: {content_ref:?}"))
214}
215
216fn content_ref(path: impl AsRef<Path>) -> Result<ContentRef> {
217    let path = std::fs::canonicalize(path)?;
218    let url = Url::from_file_path(path).map_err(|_| anyhow!("couldn't build file URL"))?;
219    Ok(ContentRef {
220        source: Some(url.to_string()),
221        ..Default::default()
222    })
223}
224
225fn is_safe_to_join(path: impl AsRef<Path>) -> bool {
226    // This could be loosened, but currently should always be true
227    path.as_ref()
228        .components()
229        .all(|c| matches!(c, std::path::Component::Normal(_)))
230}
231
232#[cfg(test)]
233mod test {
234    use super::*;
235
236    fn locked_app_json(component: &str) -> Vec<u8> {
237        serde_json::to_vec(&serde_json::json!({
238            "spin_lock_version": 1,
239            "metadata": { "name": component },
240            "triggers": [],
241            "components": [],
242        }))
243        .unwrap()
244    }
245
246    fn manifest_with_locked_app(locked_app: &[u8]) -> oci_client::manifest::OciImageManifest {
247        let digest = format!(
248            "sha256:{}",
249            spin_common::sha256::hex_digest_from_bytes(locked_app)
250        );
251        oci_client::manifest::OciImageManifest {
252            layers: vec![oci_client::manifest::OciDescriptor {
253                media_type: crate::client::SPIN_APPLICATION_MEDIA_TYPE.to_owned(),
254                digest,
255                ..Default::default()
256            }],
257            ..Default::default()
258        }
259    }
260
261    async fn mount_test_component_assets(
262        component_id: &str,
263        working_dir: &Path,
264        cache_dir: &Path,
265    ) -> anyhow::Result<()> {
266        let loader = OciLoader::new(working_dir);
267        let cache = Cache::new(Some(cache_dir.into())).await?;
268
269        let fake_wasm = vec![1, 2, 3, 4];
270        let fake_wasm_digest = spin_common::sha256::hex_digest_from_bytes(&fake_wasm);
271        cache.write_wasm(&fake_wasm, &fake_wasm_digest).await?;
272
273        let fake_file = "some content".into();
274        let fake_file_digest = spin_common::sha256::hex_digest_from_bytes(&fake_file);
275        cache.write_data(&fake_file, &fake_file_digest).await?;
276
277        let mut component = LockedComponent {
278            id: component_id.into(),
279            metadata: Default::default(),
280            source: spin_locked_app::locked::LockedComponentSource {
281                content_type: "application/wasm".into(),
282                content: ContentRef {
283                    source: None,
284                    inline: Some(fake_wasm),
285                    digest: Some(fake_wasm_digest),
286                },
287            },
288            env: Default::default(),
289            files: vec![ContentPath {
290                content: ContentRef {
291                    source: None,
292                    inline: Some(fake_file),
293                    digest: Some(fake_file_digest),
294                },
295                path: "file.txt".into(),
296            }],
297            config: Default::default(),
298            dependencies: Default::default(),
299            trigger_dependencies: Default::default(),
300            host_requirements: Default::default(),
301        };
302
303        loader
304            .resolve_component_content_refs(&mut component, &cache)
305            .await
306    }
307
308    #[tokio::test]
309    async fn locked_app_of_another_artifact_is_rejected() -> anyhow::Result<()> {
310        let working_dir = tempfile::tempdir()?;
311        let cache_dir = tempfile::tempdir()?;
312        let cache = Cache::new(Some(cache_dir.path().into())).await?;
313        let loader = OciLoader::new(working_dir.path());
314        let reference = "example.com/org/app@sha256:0a867093096e0ef01ef749b12b6e7a90e4952eda107f89a676eeedce63a8361f";
315
316        let requested = locked_app_json("requested");
317        let lockfile_path = working_dir.path().join("config.json");
318
319        // The cached locked app belongs to another artifact.
320        std::fs::write(&lockfile_path, locked_app_json("other"))?;
321        let err = loader
322            .load_from_cache(
323                manifest_with_locked_app(&requested),
324                lockfile_path.clone(),
325                reference,
326                &cache,
327            )
328            .await
329            .err()
330            .expect("a locked app that does not match the manifest should be rejected");
331        assert!(
332            format!("{err:#}").contains("is not the locked app of"),
333            "unexpected error: {err:#}"
334        );
335
336        // The cached locked app is the requested one.
337        std::fs::write(&lockfile_path, &requested)?;
338        let artifact = loader
339            .load_from_cache(
340                manifest_with_locked_app(&requested),
341                lockfile_path,
342                reference,
343                &cache,
344            )
345            .await?;
346        assert!(matches!(artifact, ExecutableArtifact::Application(_)));
347
348        Ok(())
349    }
350
351    #[tokio::test]
352    async fn component_id_cannot_be_used_to_escape_asset_directory() -> anyhow::Result<()> {
353        let component_id = "../../crimeburger";
354
355        let base_dir = tempfile::tempdir()?;
356        let cache_dir = tempfile::tempdir()?;
357        let working_dir = base_dir.path().join("good");
358
359        let res = mount_test_component_assets(component_id, &working_dir, cache_dir.path()).await;
360
361        // Validate that the file was not written - checking the error is insufficient
362        // because it could write and then fail.
363        assert!(
364            !base_dir.path().join("crimeburger/file.txt").exists(),
365            "crimeburger file exists when it shouldn't"
366        );
367        res.expect_err("illegal component id should have caused error");
368
369        Ok(())
370    }
371
372    #[tokio::test]
373    async fn legal_component_id_is_accepted() -> anyhow::Result<()> {
374        let component_id = "legal";
375
376        let base_dir = tempfile::tempdir()?;
377        let cache_dir = tempfile::tempdir()?;
378        let working_dir = base_dir.path().join("good");
379
380        mount_test_component_assets(component_id, &working_dir, cache_dir.path()).await?;
381
382        assert!(
383            working_dir.join("assets/legal/file.txt").exists(),
384            "file was not written when it should be"
385        );
386
387        Ok(())
388    }
389}