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
13pub struct OciLoader {
15 working_dir: PathBuf,
16}
17
18pub enum ExecutableArtifact {
20 Application(LockedApp),
22 Package(PathBuf),
24}
25
26impl OciLoader {
27 pub fn new(working_dir: impl Into<PathBuf>) -> Self {
30 let working_dir = working_dir.into();
31 Self { working_dir }
32 }
33
34 pub async fn load_app(
36 &self,
37 client: &mut Client,
38 reference: &str,
39 ) -> Result<ExecutableArtifact> {
40 let (manifest, digest) = client.pull_with_digest(reference).await.with_context(|| {
42 format!("cannot pull Spin application from registry reference {reference:?}")
43 })?;
44
45 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 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 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]; let wasm_path = cache.wasm_path(&layer.digest);
114 Ok(ExecutableArtifact::Package(wasm_path))
115 }
116 }
117
118 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 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 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 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 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 let digest = content_digest(&file.content)?;
186 let content_path = cache.data_file(digest)?;
187 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 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 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 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 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}