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(crate) const ASSETS_DIR: &str = "assets";
16
17pub struct OciLoader {
19 working_dir: PathBuf,
20}
21
22pub enum ExecutableArtifact {
24 Application(LockedApp),
26 Package(PathBuf),
28}
29
30impl OciLoader {
31 pub fn new(working_dir: impl Into<PathBuf>) -> Self {
34 let working_dir = working_dir.into();
35 Self { working_dir }
36 }
37
38 pub async fn load_app(
40 &self,
41 client: &mut Client,
42 reference: &str,
43 ) -> Result<ExecutableArtifact> {
44 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 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]; let wasm_path = cache.wasm_path(&layer.digest);
81 Ok(ExecutableArtifact::Package(wasm_path))
82 }
83 }
84 }
85
86 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 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
131pub(crate) async fn cached_lockfile_path(
134 client: &Client,
135 reference: &str,
136 digest: &str,
137) -> Result<PathBuf> {
138 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
152pub(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 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
193pub(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
217pub(crate) async fn write_component_files(
224 component_id: &str,
225 files: &[ContentPath],
226 cache: &Cache,
227 assets_dir: &Path,
228) -> Result<PathBuf> {
229 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 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 tokio::fs::write(&mount_path, content_bytes)
251 .await
252 .with_context(|| format!("failed to write inline content to {mount_path:?}"))?;
253 } else {
254 let digest = content_digest(&file.content)?;
256 let content_path = cache.data_file(digest)?;
257 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 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 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 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 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 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 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}