spin_oci/
loader.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{anyhow, ensure, Context, Result};
4use oci_distribution::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, ORIGIN_URL_SCHEME};
11
12/// OciLoader loads an OCI app in preparation for running with Spin.
13pub struct OciLoader {
14    working_dir: PathBuf,
15}
16
17/// The artifact loaded by the `OciLoader`
18pub enum ExecutableArtifact {
19    /// The OCI reference contained a Spin application
20    Application(LockedApp),
21    /// The OCI reference contained a Wasm package
22    Package(PathBuf),
23}
24
25impl OciLoader {
26    /// Creates a new OciLoader which builds temporary mount directory(s) in
27    /// the given working_dir.
28    pub fn new(working_dir: impl Into<PathBuf>) -> Self {
29        let working_dir = working_dir.into();
30        Self { working_dir }
31    }
32
33    /// Pulls and loads an OCI Artifact and returns a LockedApp with the given OCI client and reference
34    pub async fn load_app(
35        &self,
36        client: &mut Client,
37        reference: &str,
38    ) -> Result<ExecutableArtifact> {
39        // Fetch app
40        let manifest = client.pull(reference).await.with_context(|| {
41            format!("cannot pull Spin application from registry reference {reference:?}")
42        })?;
43
44        // Read locked app
45        let lockfile_path = client
46            .lockfile_path(&reference)
47            .await
48            .context("cannot get path to spin.lock")?;
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_distribution::manifest::OciImageManifest,
57        lockfile_path: PathBuf,
58        reference: &str,
59        cache: &Cache,
60    ) -> std::result::Result<ExecutableArtifact, anyhow::Error> {
61        let locked_content = tokio::fs::read(&lockfile_path)
62            .await
63            .with_context(|| format!("failed to read from {}", quoted_path(&lockfile_path)))?;
64        let locked_json: serde_json::Value = serde_json::from_slice(&locked_content)
65            .with_context(|| format!("OCI config {} is not JSON", quoted_path(&lockfile_path)))?;
66
67        if locked_json.get("spin_lock_version").is_some() {
68            let mut locked_app = LockedApp::from_json(&locked_content).with_context(|| {
69                format!(
70                    "failed to decode locked app from {}",
71                    quoted_path(&lockfile_path)
72                )
73            })?;
74
75            // Update origin metadata
76            let resolved_reference = Reference::try_from(reference).context("invalid reference")?;
77            let origin_uri = format!("{ORIGIN_URL_SCHEME}:{resolved_reference}");
78            locked_app
79                .metadata
80                .insert("origin".to_string(), origin_uri.into());
81
82            for component in &mut locked_app.components {
83                self.resolve_component_content_refs(component, cache)
84                    .await
85                    .with_context(|| {
86                        format!("failed to resolve content for component {:?}", component.id)
87                    })?;
88            }
89            Ok(ExecutableArtifact::Application(locked_app))
90        } else {
91            if manifest.layers.len() != 1 {
92                anyhow::bail!(
93                    "expected single layer in OCI package, found {} layers",
94                    manifest.layers.len()
95                );
96            }
97            let layer = &manifest.layers[0]; // guaranteed safe by previous check
98            let wasm_path = cache.wasm_path(&layer.digest);
99            Ok(ExecutableArtifact::Package(wasm_path))
100        }
101    }
102
103    async fn resolve_component_content_refs(
104        &self,
105        component: &mut LockedComponent,
106        cache: &Cache,
107    ) -> Result<()> {
108        // Update wasm content path
109        let wasm_digest = content_digest(&component.source.content)?;
110        let wasm_path = cache.wasm_file(wasm_digest)?;
111        component.source.content = content_ref(wasm_path)?;
112
113        for dep in &mut component.dependencies.values_mut() {
114            let dep_wasm_digest = content_digest(&dep.source.content)?;
115            let dep_wasm_path = cache.wasm_file(dep_wasm_digest)?;
116            dep.source.content = content_ref(dep_wasm_path)?;
117        }
118
119        if !component.files.is_empty() {
120            let mount_dir = self.working_dir.join("assets").join(&component.id);
121            for file in &mut component.files {
122                ensure!(is_safe_to_join(&file.path), "invalid file mount {file:?}");
123                let mount_path = mount_dir.join(&file.path);
124
125                // Create parent directory
126                let mount_parent = mount_path
127                    .parent()
128                    .with_context(|| format!("invalid mount path {mount_path:?}"))?;
129                tokio::fs::create_dir_all(mount_parent)
130                    .await
131                    .with_context(|| {
132                        format!("failed to create temporary mount path {mount_path:?}")
133                    })?;
134
135                if let Some(content_bytes) = file.content.inline.as_deref() {
136                    // Write inline content to disk
137                    tokio::fs::write(&mount_path, content_bytes)
138                        .await
139                        .with_context(|| {
140                            format!("failed to write inline content to {mount_path:?}")
141                        })?;
142                } else {
143                    // Copy content
144                    let digest = content_digest(&file.content)?;
145                    let content_path = cache.data_file(digest)?;
146                    // TODO: parallelize
147                    tokio::fs::copy(&content_path, &mount_path)
148                        .await
149                        .with_context(|| {
150                            format!(
151                                "failed to copy {}->{mount_path:?}",
152                                quoted_path(&content_path)
153                            )
154                        })?;
155                }
156            }
157
158            component.files = vec![ContentPath {
159                content: content_ref(mount_dir)?,
160                path: "/".into(),
161            }]
162        }
163
164        Ok(())
165    }
166}
167
168fn content_digest(content_ref: &ContentRef) -> Result<&str> {
169    content_ref
170        .digest
171        .as_deref()
172        .with_context(|| format!("content missing expected digest: {content_ref:?}"))
173}
174
175fn content_ref(path: impl AsRef<Path>) -> Result<ContentRef> {
176    let path = std::fs::canonicalize(path)?;
177    let url = Url::from_file_path(path).map_err(|_| anyhow!("couldn't build file URL"))?;
178    Ok(ContentRef {
179        source: Some(url.to_string()),
180        ..Default::default()
181    })
182}
183
184fn is_safe_to_join(path: impl AsRef<Path>) -> bool {
185    // This could be loosened, but currently should always be true
186    path.as_ref()
187        .components()
188        .all(|c| matches!(c, std::path::Component::Normal(_)))
189}