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
17pub enum ExecutableArtifact {
19 Application(LockedApp),
21 Package(PathBuf),
23}
24
25impl OciLoader {
26 pub fn new(working_dir: impl Into<PathBuf>) -> Self {
29 let working_dir = working_dir.into();
30 Self { working_dir }
31 }
32
33 pub async fn load_app(
35 &self,
36 client: &mut Client,
37 reference: &str,
38 ) -> Result<ExecutableArtifact> {
39 let manifest = client.pull(reference).await.with_context(|| {
41 format!("cannot pull Spin application from registry reference {reference:?}")
42 })?;
43
44 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 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 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]; let wasm_path = cache.wasm_path(&layer.digest);
99 Ok(ExecutableArtifact::Package(wasm_path))
100 }
101 }
102
103 pub async fn resolve_component_content_refs(
114 &self,
115 component: &mut LockedComponent,
116 cache: &Cache,
117 ) -> Result<()> {
118 let wasm_digest = content_digest(&component.source.content)?;
120 let wasm_path = cache.wasm_file(wasm_digest)?;
121 component.source.content = content_ref(wasm_path)?;
122
123 for dep in &mut component.dependencies.values_mut() {
124 let dep_wasm_digest = content_digest(&dep.source.content)?;
125 let dep_wasm_path = cache.wasm_file(dep_wasm_digest)?;
126 dep.source.content = content_ref(dep_wasm_path)?;
127 }
128
129 if !component.files.is_empty() {
130 let mount_dir = self.working_dir.join("assets").join(&component.id);
131 for file in &mut component.files {
132 ensure!(is_safe_to_join(&file.path), "invalid file mount {file:?}");
133 let mount_path = mount_dir.join(&file.path);
134
135 let mount_parent = mount_path
137 .parent()
138 .with_context(|| format!("invalid mount path {mount_path:?}"))?;
139 tokio::fs::create_dir_all(mount_parent)
140 .await
141 .with_context(|| {
142 format!("failed to create temporary mount path {mount_path:?}")
143 })?;
144
145 if let Some(content_bytes) = file.content.inline.as_deref() {
146 tokio::fs::write(&mount_path, content_bytes)
148 .await
149 .with_context(|| {
150 format!("failed to write inline content to {mount_path:?}")
151 })?;
152 } else {
153 let digest = content_digest(&file.content)?;
155 let content_path = cache.data_file(digest)?;
156 tokio::fs::copy(&content_path, &mount_path)
158 .await
159 .with_context(|| {
160 format!(
161 "failed to copy {}->{mount_path:?}",
162 quoted_path(&content_path)
163 )
164 })?;
165 }
166 }
167
168 component.files = vec![ContentPath {
169 content: content_ref(mount_dir)?,
170 path: "/".into(),
171 }]
172 }
173
174 Ok(())
175 }
176}
177
178fn content_digest(content_ref: &ContentRef) -> Result<&str> {
179 content_ref
180 .digest
181 .as_deref()
182 .with_context(|| format!("content missing expected digest: {content_ref:?}"))
183}
184
185fn content_ref(path: impl AsRef<Path>) -> Result<ContentRef> {
186 let path = std::fs::canonicalize(path)?;
187 let url = Url::from_file_path(path).map_err(|_| anyhow!("couldn't build file URL"))?;
188 Ok(ContentRef {
189 source: Some(url.to_string()),
190 ..Default::default()
191 })
192}
193
194fn is_safe_to_join(path: impl AsRef<Path>) -> bool {
195 path.as_ref()
197 .components()
198 .all(|c| matches!(c, std::path::Component::Normal(_)))
199}