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
17impl OciLoader {
18    /// Creates a new OciLoader which builds temporary mount directory(s) in
19    /// the given working_dir.
20    pub fn new(working_dir: impl Into<PathBuf>) -> Self {
21        let working_dir = working_dir.into();
22        Self { working_dir }
23    }
24
25    /// Pulls and loads an OCI Artifact and returns a LockedApp with the given OCI client and reference
26    pub async fn load_app(&self, client: &mut Client, reference: &str) -> Result<LockedApp> {
27        // Fetch app
28        client.pull(reference).await.with_context(|| {
29            format!("cannot pull Spin application from registry reference {reference:?}")
30        })?;
31
32        // Read locked app
33        let lockfile_path = client
34            .lockfile_path(&reference)
35            .await
36            .context("cannot get path to spin.lock")?;
37        self.load_from_cache(lockfile_path, reference, &client.cache)
38            .await
39    }
40
41    /// Loads an OCI Artifact from the given cache and returns a LockedApp with the given reference
42    pub async fn load_from_cache(
43        &self,
44        lockfile_path: PathBuf,
45        reference: &str,
46        cache: &Cache,
47    ) -> std::result::Result<LockedApp, anyhow::Error> {
48        let locked_content = tokio::fs::read(&lockfile_path)
49            .await
50            .with_context(|| format!("failed to read from {}", quoted_path(&lockfile_path)))?;
51        let mut locked_app = LockedApp::from_json(&locked_content).with_context(|| {
52            format!(
53                "failed to decode locked app from {}",
54                quoted_path(&lockfile_path)
55            )
56        })?;
57
58        // Update origin metadata
59        let resolved_reference = Reference::try_from(reference).context("invalid reference")?;
60        let origin_uri = format!("{ORIGIN_URL_SCHEME}:{resolved_reference}");
61        locked_app
62            .metadata
63            .insert("origin".to_string(), origin_uri.into());
64
65        for component in &mut locked_app.components {
66            self.resolve_component_content_refs(component, cache)
67                .await
68                .with_context(|| {
69                    format!("failed to resolve content for component {:?}", component.id)
70                })?;
71        }
72        Ok(locked_app)
73    }
74
75    async fn resolve_component_content_refs(
76        &self,
77        component: &mut LockedComponent,
78        cache: &Cache,
79    ) -> Result<()> {
80        // Update wasm content path
81        let wasm_digest = content_digest(&component.source.content)?;
82        let wasm_path = cache.wasm_file(wasm_digest)?;
83        component.source.content = content_ref(wasm_path)?;
84
85        for dep in &mut component.dependencies.values_mut() {
86            let dep_wasm_digest = content_digest(&dep.source.content)?;
87            let dep_wasm_path = cache.wasm_file(dep_wasm_digest)?;
88            dep.source.content = content_ref(dep_wasm_path)?;
89        }
90
91        if !component.files.is_empty() {
92            let mount_dir = self.working_dir.join("assets").join(&component.id);
93            for file in &mut component.files {
94                ensure!(is_safe_to_join(&file.path), "invalid file mount {file:?}");
95                let mount_path = mount_dir.join(&file.path);
96
97                // Create parent directory
98                let mount_parent = mount_path
99                    .parent()
100                    .with_context(|| format!("invalid mount path {mount_path:?}"))?;
101                tokio::fs::create_dir_all(mount_parent)
102                    .await
103                    .with_context(|| {
104                        format!("failed to create temporary mount path {mount_path:?}")
105                    })?;
106
107                if let Some(content_bytes) = file.content.inline.as_deref() {
108                    // Write inline content to disk
109                    tokio::fs::write(&mount_path, content_bytes)
110                        .await
111                        .with_context(|| {
112                            format!("failed to write inline content to {mount_path:?}")
113                        })?;
114                } else {
115                    // Copy content
116                    let digest = content_digest(&file.content)?;
117                    let content_path = cache.data_file(digest)?;
118                    // TODO: parallelize
119                    tokio::fs::copy(&content_path, &mount_path)
120                        .await
121                        .with_context(|| {
122                            format!(
123                                "failed to copy {}->{mount_path:?}",
124                                quoted_path(&content_path)
125                            )
126                        })?;
127                }
128            }
129
130            component.files = vec![ContentPath {
131                content: content_ref(mount_dir)?,
132                path: "/".into(),
133            }]
134        }
135
136        Ok(())
137    }
138}
139
140fn content_digest(content_ref: &ContentRef) -> Result<&str> {
141    content_ref
142        .digest
143        .as_deref()
144        .with_context(|| format!("content missing expected digest: {content_ref:?}"))
145}
146
147fn content_ref(path: impl AsRef<Path>) -> Result<ContentRef> {
148    let path = std::fs::canonicalize(path)?;
149    let url = Url::from_file_path(path).map_err(|_| anyhow!("couldn't build file URL"))?;
150    Ok(ContentRef {
151        source: Some(url.to_string()),
152        ..Default::default()
153    })
154}
155
156fn is_safe_to_join(path: impl AsRef<Path>) -> bool {
157    // This could be loosened, but currently should always be true
158    path.as_ref()
159        .components()
160        .all(|c| matches!(c, std::path::Component::Normal(_)))
161}