1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, anyhow};
5use clap::{Args, Subcommand};
6use serde_json::Value;
7
8use crate::cmd::i18n;
9use crate::path_safety::normalize_under_root;
10use greentic_distributor_client::{CachePolicy, DistClient, DistOptions, ResolvePolicy};
11
12#[derive(Subcommand, Debug, Clone)]
13pub enum StoreCommand {
14 Fetch(StoreFetchArgs),
16}
17
18#[derive(Args, Debug, Clone)]
19pub struct StoreFetchArgs {
20 #[arg(long, value_name = "DIR")]
22 pub out: PathBuf,
23 #[arg(long, value_name = "DIR")]
25 pub cache_dir: Option<PathBuf>,
26 #[arg(value_name = "SOURCE")]
28 pub source: String,
29}
30
31pub fn run(command: StoreCommand) -> Result<()> {
32 match command {
33 StoreCommand::Fetch(args) => fetch(args),
34 }
35}
36
37fn fetch(args: StoreFetchArgs) -> Result<()> {
38 let source = resolve_source(&args.source)?;
39 let mut opts = DistOptions::default();
40 if let Some(cache_dir) = &args.cache_dir {
41 opts.cache_dir = cache_dir.clone();
42 }
43 let client = DistClient::new(opts);
44 let rt =
45 tokio::runtime::Runtime::new().context(i18n::tr_lit("failed to create async runtime"))?;
46 let parsed = client.parse_source(&source)?;
47 let resolved = rt
48 .block_on(async {
49 let descriptor = client.resolve(parsed, ResolvePolicy).await?;
50 client.fetch(&descriptor, CachePolicy).await
51 })
52 .context(i18n::tr_lit("store fetch failed"))?;
53 let cache_path = resolved
54 .cache_path
55 .ok_or_else(|| anyhow!(i18n::tr_lit("resolved source has no cached component path")))?;
56 let (out_dir, wasm_override) = resolve_output_paths(&args.out)?;
57 fs::create_dir_all(&out_dir).with_context(|| {
58 i18n::tr_lit("failed to create output dir {}").replacen(
59 "{}",
60 &out_dir.display().to_string(),
61 1,
62 )
63 })?;
64 let manifest_cache_path = cache_path
65 .parent()
66 .map(|dir| dir.join("component.manifest.json"));
67 let manifest_out_path = out_dir.join("component.manifest.json");
68 let mut wasm_out_path = wasm_override
69 .clone()
70 .unwrap_or_else(|| out_dir.join("component.wasm"));
71 if let Some(manifest_cache_path) = manifest_cache_path
72 && manifest_cache_path.exists()
73 {
74 let manifest_bytes = fs::read(&manifest_cache_path).with_context(|| {
75 i18n::tr_lit("failed to read cached manifest {}").replacen(
76 "{}",
77 &manifest_cache_path.display().to_string(),
78 1,
79 )
80 })?;
81 fs::write(&manifest_out_path, &manifest_bytes).with_context(|| {
82 i18n::tr_lit("failed to write manifest {}").replacen(
83 "{}",
84 &manifest_out_path.display().to_string(),
85 1,
86 )
87 })?;
88 let manifest: Value = serde_json::from_slice(&manifest_bytes).with_context(|| {
89 i18n::tr_lit("failed to parse component.manifest.json from {}").replacen(
90 "{}",
91 &manifest_cache_path.display().to_string(),
92 1,
93 )
94 })?;
95 if let Some(component_wasm) = manifest
96 .get("artifacts")
97 .and_then(|artifacts| artifacts.get("component_wasm"))
98 .and_then(|value| value.as_str())
99 {
100 let candidate = PathBuf::from(component_wasm);
101 if wasm_override.is_none() {
102 wasm_out_path = normalize_under_root(&out_dir, &candidate).with_context(|| {
103 i18n::tr_lit("invalid artifacts.component_wasm path `{}`").replacen(
104 "{}",
105 component_wasm,
106 1,
107 )
108 })?;
109 if let Some(parent) = wasm_out_path.parent() {
110 fs::create_dir_all(parent).with_context(|| {
111 i18n::tr_lit("failed to create output dir {}").replacen(
112 "{}",
113 &parent.display().to_string(),
114 1,
115 )
116 })?;
117 }
118 }
119 }
120 }
121 fs::copy(&cache_path, &wasm_out_path).with_context(|| {
122 i18n::tr_lit("failed to copy cached component {} to {}")
123 .replacen("{}", &cache_path.display().to_string(), 1)
124 .replacen("{}", &wasm_out_path.display().to_string(), 1)
125 })?;
126 println!(
127 "{}",
128 i18n::tr_lit("Wrote {} (digest {}) for source {}")
129 .replacen("{}", &wasm_out_path.display().to_string(), 1)
130 .replacen("{}", &resolved.digest.to_string(), 1)
131 .replacen("{}", &source, 1)
132 );
133 if manifest_out_path.exists() {
134 println!(
135 "{}",
136 i18n::tr_lit("Wrote {}").replacen("{}", &manifest_out_path.display().to_string(), 1)
137 );
138 }
139 Ok(())
140}
141
142fn resolve_source(source: &str) -> Result<String> {
143 let (prefix, path_str) = if let Some(rest) = source.strip_prefix("file://") {
144 ("file://", rest)
145 } else {
146 ("", source)
147 };
148 let path = Path::new(path_str);
149 if !path.is_dir() {
150 return Ok(source.to_string());
151 }
152
153 let manifest_path = path.join("component.manifest.json");
154 if manifest_path.exists() {
155 let manifest_bytes = fs::read(&manifest_path).with_context(|| {
156 i18n::tr_lit("failed to read component.manifest.json at {}").replacen(
157 "{}",
158 &manifest_path.display().to_string(),
159 1,
160 )
161 })?;
162 let manifest: Value = serde_json::from_slice(&manifest_bytes).with_context(|| {
163 i18n::tr_lit("failed to parse component.manifest.json at {}").replacen(
164 "{}",
165 &manifest_path.display().to_string(),
166 1,
167 )
168 })?;
169 if let Some(component_wasm) = manifest
170 .get("artifacts")
171 .and_then(|artifacts| artifacts.get("component_wasm"))
172 .and_then(|value| value.as_str())
173 {
174 let wasm_path =
175 normalize_under_root(path, Path::new(component_wasm)).with_context(|| {
176 i18n::tr_lit("invalid artifacts.component_wasm path `{}`").replacen(
177 "{}",
178 component_wasm,
179 1,
180 )
181 })?;
182 return Ok(format!("{prefix}{}", wasm_path.display()));
183 }
184 }
185
186 let wasm_path = path.join("component.wasm");
187 if wasm_path.exists() {
188 return Ok(format!("{prefix}{}", wasm_path.display()));
189 }
190
191 Err(anyhow!(
192 "{}",
193 i18n::tr_lit(
194 "source directory {} does not contain component.manifest.json or component.wasm"
195 )
196 .replacen("{}", &path.display().to_string(), 1)
197 ))
198}
199
200fn resolve_output_paths(out: &std::path::Path) -> Result<(PathBuf, Option<PathBuf>)> {
201 if out.exists() {
202 if out.is_dir() {
203 return Ok((out.to_path_buf(), None));
204 }
205 if let Some(parent) = out.parent() {
206 return Ok((parent.to_path_buf(), Some(out.to_path_buf())));
207 }
208 return Ok((PathBuf::from("."), Some(out.to_path_buf())));
209 }
210
211 if out.extension().is_some() {
212 let parent = out.parent().unwrap_or_else(|| std::path::Path::new("."));
213 return Ok((parent.to_path_buf(), Some(out.to_path_buf())));
214 }
215
216 Ok((out.to_path_buf(), None))
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use serde_json::json;
223
224 #[test]
225 fn resolve_output_paths_treats_existing_directory_as_output_dir() {
226 let dir = tempfile::tempdir().expect("tempdir");
227 let (out_dir, wasm) = resolve_output_paths(dir.path()).unwrap();
228 assert_eq!(out_dir, dir.path());
229 assert_eq!(wasm, None);
230 }
231
232 #[test]
233 fn resolve_output_paths_treats_file_like_path_as_wasm_override() {
234 let out = PathBuf::from("dist/component.wasm");
235 let (out_dir, wasm) = resolve_output_paths(&out).unwrap();
236 assert_eq!(out_dir, PathBuf::from("dist"));
237 assert_eq!(wasm, Some(out));
238 }
239
240 #[test]
241 fn resolve_source_prefers_manifest_artifact_inside_directory() {
242 let dir = tempfile::tempdir().expect("tempdir");
243 let nested = dir.path().join("dist");
244 fs::create_dir_all(&nested).expect("dist dir");
245 let wasm = nested.join("component.wasm");
246 fs::write(&wasm, b"wasm").expect("write wasm");
247 fs::write(
248 dir.path().join("component.manifest.json"),
249 serde_json::to_string_pretty(&json!({
250 "artifacts": { "component_wasm": "dist/component.wasm" }
251 }))
252 .unwrap(),
253 )
254 .expect("write manifest");
255
256 let resolved = resolve_source(dir.path().to_str().unwrap()).unwrap();
257 assert!(resolved.ends_with("dist/component.wasm"));
258 }
259
260 #[test]
261 fn resolve_source_falls_back_to_component_wasm_in_directory() {
262 let dir = tempfile::tempdir().expect("tempdir");
263 let wasm = dir.path().join("component.wasm");
264 fs::write(&wasm, b"wasm").expect("write wasm");
265
266 let resolved = resolve_source(dir.path().to_str().unwrap()).unwrap();
267 assert!(resolved.ends_with("component.wasm"));
268 }
269
270 #[test]
271 fn resolve_source_rejects_empty_directory_without_component_files() {
272 let dir = tempfile::tempdir().expect("tempdir");
273 let err = resolve_source(dir.path().to_str().unwrap()).expect_err("should fail");
274 assert!(
275 err.to_string()
276 .contains("does not contain component.manifest.json or component.wasm")
277 );
278 }
279
280 #[test]
281 fn resolve_source_passthroughs_non_directory_references() {
282 let source = "oci://registry.example.com/component:1.0.0";
283 assert_eq!(resolve_source(source).unwrap(), source);
284 }
285
286 #[test]
287 fn resolve_source_preserves_file_scheme_for_directory_inputs() {
288 let dir = tempfile::tempdir().expect("tempdir");
289 let wasm = dir.path().join("component.wasm");
290 fs::write(&wasm, b"wasm").expect("write wasm");
291 let source = format!("file://{}", dir.path().display());
292
293 let resolved = resolve_source(&source).expect("resolve file dir");
294
295 assert!(resolved.starts_with("file://"));
296 assert!(resolved.ends_with("component.wasm"));
297 }
298
299 #[test]
300 fn resolve_source_rejects_manifest_artifact_that_escapes_directory() {
301 let dir = tempfile::tempdir().expect("tempdir");
302 fs::write(
303 dir.path().join("component.manifest.json"),
304 serde_json::to_string_pretty(&json!({
305 "artifacts": { "component_wasm": "../escape.wasm" }
306 }))
307 .unwrap(),
308 )
309 .expect("write manifest");
310
311 let err = resolve_source(dir.path().to_str().unwrap()).expect_err("escape should fail");
312
313 assert!(
314 err.to_string()
315 .contains("invalid artifacts.component_wasm path")
316 );
317 }
318
319 #[test]
320 fn resolve_output_paths_treats_existing_file_as_override_in_parent_directory() {
321 let dir = tempfile::tempdir().expect("tempdir");
322 let out = dir.path().join("downloaded.wasm");
323 fs::write(&out, b"old").expect("write existing output");
324
325 let (out_dir, wasm) = resolve_output_paths(&out).expect("resolve output paths");
326
327 assert_eq!(out_dir, dir.path());
328 assert_eq!(wasm, Some(out));
329 }
330
331 #[test]
332 fn resolve_output_paths_treats_extensionless_missing_path_as_directory() {
333 let out = PathBuf::from("dist/component");
334 let (out_dir, wasm) = resolve_output_paths(&out).expect("resolve output paths");
335
336 assert_eq!(out_dir, out);
337 assert_eq!(wasm, None);
338 }
339}