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
12pub struct OciLoader {
14 working_dir: PathBuf,
15}
16
17impl OciLoader {
18 pub fn new(working_dir: impl Into<PathBuf>) -> Self {
21 let working_dir = working_dir.into();
22 Self { working_dir }
23 }
24
25 pub async fn load_app(&self, client: &mut Client, reference: &str) -> Result<LockedApp> {
27 client.pull(reference).await.with_context(|| {
29 format!("cannot pull Spin application from registry reference {reference:?}")
30 })?;
31
32 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 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 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 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 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 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 let digest = content_digest(&file.content)?;
117 let content_path = cache.data_file(digest)?;
118 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 path.as_ref()
159 .components()
160 .all(|c| matches!(c, std::path::Component::Normal(_)))
161}