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 async fn resolve_component_content_refs(
104 &self,
105 component: &mut LockedComponent,
106 cache: &Cache,
107 ) -> Result<()> {
108 let wasm_digest = content_digest(&component.source.content)?;
110 let wasm_path = cache.wasm_file(wasm_digest)?;
111 component.source.content = content_ref(wasm_path)?;
112
113 for dep in &mut component.dependencies.values_mut() {
114 let dep_wasm_digest = content_digest(&dep.source.content)?;
115 let dep_wasm_path = cache.wasm_file(dep_wasm_digest)?;
116 dep.source.content = content_ref(dep_wasm_path)?;
117 }
118
119 if !component.files.is_empty() {
120 let mount_dir = self.working_dir.join("assets").join(&component.id);
121 for file in &mut component.files {
122 ensure!(is_safe_to_join(&file.path), "invalid file mount {file:?}");
123 let mount_path = mount_dir.join(&file.path);
124
125 let mount_parent = mount_path
127 .parent()
128 .with_context(|| format!("invalid mount path {mount_path:?}"))?;
129 tokio::fs::create_dir_all(mount_parent)
130 .await
131 .with_context(|| {
132 format!("failed to create temporary mount path {mount_path:?}")
133 })?;
134
135 if let Some(content_bytes) = file.content.inline.as_deref() {
136 tokio::fs::write(&mount_path, content_bytes)
138 .await
139 .with_context(|| {
140 format!("failed to write inline content to {mount_path:?}")
141 })?;
142 } else {
143 let digest = content_digest(&file.content)?;
145 let content_path = cache.data_file(digest)?;
146 tokio::fs::copy(&content_path, &mount_path)
148 .await
149 .with_context(|| {
150 format!(
151 "failed to copy {}->{mount_path:?}",
152 quoted_path(&content_path)
153 )
154 })?;
155 }
156 }
157
158 component.files = vec![ContentPath {
159 content: content_ref(mount_dir)?,
160 path: "/".into(),
161 }]
162 }
163
164 Ok(())
165 }
166}
167
168fn content_digest(content_ref: &ContentRef) -> Result<&str> {
169 content_ref
170 .digest
171 .as_deref()
172 .with_context(|| format!("content missing expected digest: {content_ref:?}"))
173}
174
175fn content_ref(path: impl AsRef<Path>) -> Result<ContentRef> {
176 let path = std::fs::canonicalize(path)?;
177 let url = Url::from_file_path(path).map_err(|_| anyhow!("couldn't build file URL"))?;
178 Ok(ContentRef {
179 source: Some(url.to_string()),
180 ..Default::default()
181 })
182}
183
184fn is_safe_to_join(path: impl AsRef<Path>) -> bool {
185 path.as_ref()
187 .components()
188 .all(|c| matches!(c, std::path::Component::Normal(_)))
189}