greentic_setup/
bundle_source.rs1use std::path::{Path, PathBuf};
6
7use anyhow::{Context, anyhow};
8
9#[derive(Clone, Debug)]
11pub enum BundleSource {
12 LocalDir(PathBuf),
14 FileUri(PathBuf),
16 #[cfg(feature = "oci")]
18 Oci { reference: String },
19 #[cfg(feature = "oci")]
21 Repo { reference: String },
22 #[cfg(feature = "oci")]
24 Store { reference: String },
25}
26
27impl BundleSource {
28 pub fn parse(source: &str) -> anyhow::Result<Self> {
45 let trimmed = source.trim();
46
47 if trimmed.is_empty() {
48 return Err(anyhow!("bundle source cannot be empty"));
49 }
50
51 #[cfg(feature = "oci")]
53 if trimmed.starts_with("oci://") {
54 return Ok(Self::Oci {
55 reference: trimmed.to_string(),
56 });
57 }
58
59 #[cfg(feature = "oci")]
61 if trimmed.starts_with("repo://") {
62 return Ok(Self::Repo {
63 reference: trimmed.to_string(),
64 });
65 }
66
67 #[cfg(feature = "oci")]
69 if trimmed.starts_with("store://") {
70 return Ok(Self::Store {
71 reference: trimmed.to_string(),
72 });
73 }
74
75 if trimmed.starts_with("file://") {
77 let path = file_uri_to_path(trimmed)?;
78 return Ok(Self::FileUri(path));
79 }
80
81 #[cfg(not(feature = "oci"))]
83 if trimmed.starts_with("oci://")
84 || trimmed.starts_with("repo://")
85 || trimmed.starts_with("store://")
86 {
87 return Err(anyhow!(
88 "protocol not supported (compile with 'oci' feature): {}",
89 trimmed.split("://").next().unwrap_or("unknown")
90 ));
91 }
92
93 let path = PathBuf::from(trimmed);
95 Ok(Self::LocalDir(path))
96 }
97
98 pub fn resolve(&self) -> anyhow::Result<PathBuf> {
103 match self {
104 Self::LocalDir(path) => resolve_local_path(path),
105 Self::FileUri(path) => resolve_local_path(path),
106 #[cfg(feature = "oci")]
107 Self::Oci { reference } => resolve_oci_pack_reference(reference),
108 #[cfg(feature = "oci")]
109 Self::Repo { reference } => resolve_distributor_reference(reference),
110 #[cfg(feature = "oci")]
111 Self::Store { reference } => resolve_distributor_reference(reference),
112 }
113 }
114
115 pub async fn resolve_async(&self) -> anyhow::Result<PathBuf> {
120 match self {
121 Self::LocalDir(path) => resolve_local_path(path),
122 Self::FileUri(path) => resolve_local_path(path),
123 #[cfg(feature = "oci")]
124 Self::Oci { reference } => resolve_oci_pack_reference_async(reference).await,
125 #[cfg(feature = "oci")]
126 Self::Repo { reference } => resolve_distributor_reference_async(reference).await,
127 #[cfg(feature = "oci")]
128 Self::Store { reference } => resolve_distributor_reference_async(reference).await,
129 }
130 }
131
132 pub fn as_str(&self) -> String {
134 match self {
135 Self::LocalDir(path) => path.display().to_string(),
136 Self::FileUri(path) => format!("file://{}", path.display()),
137 #[cfg(feature = "oci")]
138 Self::Oci { reference } => reference.clone(),
139 #[cfg(feature = "oci")]
140 Self::Repo { reference } => reference.clone(),
141 #[cfg(feature = "oci")]
142 Self::Store { reference } => reference.clone(),
143 }
144 }
145
146 pub fn is_local(&self) -> bool {
148 matches!(self, Self::LocalDir(_) | Self::FileUri(_))
149 }
150
151 #[cfg(feature = "oci")]
153 pub fn is_remote(&self) -> bool {
154 matches!(
155 self,
156 Self::Oci { .. } | Self::Repo { .. } | Self::Store { .. }
157 )
158 }
159}
160
161fn file_uri_to_path(uri: &str) -> anyhow::Result<PathBuf> {
163 let path_str = uri
164 .strip_prefix("file://")
165 .ok_or_else(|| anyhow!("invalid file URI: {}", uri))?;
166
167 #[cfg(windows)]
169 let path_str = path_str.strip_prefix('/').unwrap_or(path_str);
170
171 let decoded = percent_decode(path_str);
172 Ok(PathBuf::from(decoded))
173}
174
175fn percent_decode(input: &str) -> String {
177 let mut result = String::with_capacity(input.len());
178 let mut chars = input.chars().peekable();
179
180 while let Some(ch) = chars.next() {
181 if ch == '%' {
182 let hex: String = chars.by_ref().take(2).collect();
183 if hex.len() == 2
184 && let Ok(byte) = u8::from_str_radix(&hex, 16)
185 {
186 result.push(byte as char);
187 continue;
188 }
189 result.push('%');
190 result.push_str(&hex);
191 } else {
192 result.push(ch);
193 }
194 }
195
196 result
197}
198
199fn resolve_local_path(path: &Path) -> anyhow::Result<PathBuf> {
201 let canonical = if path.is_absolute() {
202 path.to_path_buf()
203 } else {
204 std::env::current_dir()
205 .context("failed to get current directory")?
206 .join(path)
207 };
208
209 if !canonical.exists() {
210 return Err(anyhow!(
211 "bundle path does not exist: {}",
212 canonical.display()
213 ));
214 }
215
216 Ok(canonical)
217}
218
219#[cfg(feature = "oci")]
221fn resolve_oci_pack_reference(reference: &str) -> anyhow::Result<PathBuf> {
222 use tokio::runtime::Runtime;
223
224 let rt = Runtime::new().context("failed to create tokio runtime")?;
225 rt.block_on(resolve_oci_pack_reference_async(reference))
226}
227
228#[cfg(feature = "oci")]
230async fn resolve_oci_pack_reference_async(reference: &str) -> anyhow::Result<PathBuf> {
231 use greentic_distributor_client::oci_packs::DefaultRegistryClient;
232 use greentic_distributor_client::{OciPackFetcher, PackFetchOptions};
233
234 let oci_reference = reference.strip_prefix("oci://").unwrap_or(reference).trim();
235 let options = PackFetchOptions {
236 allow_tags: true,
237 ..PackFetchOptions::default()
238 };
239 let fetched =
240 if let Some((username, password)) = registry_basic_auth_for_reference(oci_reference) {
241 let client = DefaultRegistryClient::with_basic_auth(username, password);
242 OciPackFetcher::with_client(client, options)
243 .fetch_pack_to_cache(oci_reference)
244 .await
245 } else {
246 OciPackFetcher::<DefaultRegistryClient>::new(options)
247 .fetch_pack_to_cache(oci_reference)
248 .await
249 }
250 .with_context(|| format!("failed to fetch OCI pack reference: {}", reference))?;
251
252 if fetched.path.exists() {
253 return Ok(fetched.path);
254 }
255
256 anyhow::bail!(
257 "resolved bundle reference without a local cached artifact: {}",
258 reference
259 );
260}
261
262#[cfg(feature = "oci")]
263fn registry_basic_auth_for_reference(reference: &str) -> Option<(String, String)> {
264 let registry = reference.split('/').next().unwrap_or_default();
265
266 let generic_username = std::env::var("OCI_USERNAME")
267 .ok()
268 .filter(|value| !value.is_empty());
269 let generic_password = std::env::var("OCI_PASSWORD")
270 .ok()
271 .filter(|value| !value.is_empty());
272 if let (Some(username), Some(password)) = (generic_username, generic_password) {
273 return Some((username, password));
274 }
275
276 if registry == "ghcr.io" {
277 let password = std::env::var("GHCR_TOKEN")
278 .ok()
279 .filter(|value| !value.is_empty())
280 .or_else(|| {
281 std::env::var("GITHUB_TOKEN")
282 .ok()
283 .filter(|value| !value.is_empty())
284 });
285 let username = std::env::var("GHCR_USERNAME")
286 .ok()
287 .filter(|value| !value.is_empty())
288 .or_else(|| {
289 std::env::var("GHCR_USER")
290 .ok()
291 .filter(|value| !value.is_empty())
292 })
293 .or_else(|| {
294 std::env::var("GITHUB_ACTOR")
295 .ok()
296 .filter(|value| !value.is_empty())
297 })
298 .or_else(|| std::env::var("USER").ok().filter(|value| !value.is_empty()));
299
300 if let (Some(username), Some(password)) = (username, password) {
301 return Some((username, password));
302 }
303 }
304
305 None
306}
307
308#[cfg(feature = "oci")]
310fn resolve_distributor_reference(reference: &str) -> anyhow::Result<PathBuf> {
311 use tokio::runtime::Runtime;
312
313 let rt = Runtime::new().context("failed to create tokio runtime")?;
314 rt.block_on(resolve_distributor_reference_async(reference))
315}
316
317#[cfg(feature = "oci")]
319async fn resolve_distributor_reference_async(reference: &str) -> anyhow::Result<PathBuf> {
320 use greentic_distributor_client::{CachePolicy, DistClient, DistOptions, ResolvePolicy};
321
322 let client = DistClient::new(DistOptions::default());
323 let source = client
324 .parse_source(reference)
325 .with_context(|| format!("failed to parse bundle reference: {}", reference))?;
326 let resolved = client
327 .resolve(source, ResolvePolicy)
328 .await
329 .with_context(|| format!("failed to resolve bundle reference: {}", reference))?;
330 let fetched = client
331 .fetch(&resolved, CachePolicy)
332 .await
333 .with_context(|| format!("failed to fetch bundle reference: {}", reference))?;
334
335 if fetched.local_path.exists() {
336 return Ok(fetched.local_path);
337 }
338 if let Some(path) = fetched.wasm_path
339 && path.exists()
340 {
341 return Ok(path);
342 }
343 if let Some(path) = fetched.cache_path
344 && path.exists()
345 {
346 return Ok(path);
347 }
348
349 anyhow::bail!(
350 "resolved bundle reference without a local cached artifact: {}",
351 reference
352 );
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn parse_local_path() {
361 let source = BundleSource::parse("./my-bundle").unwrap();
362 assert!(matches!(source, BundleSource::LocalDir(_)));
363 }
364
365 #[test]
366 fn parse_absolute_path() {
367 let source = BundleSource::parse("/home/user/bundle").unwrap();
368 assert!(matches!(source, BundleSource::LocalDir(_)));
369 }
370
371 #[test]
372 fn parse_file_uri() {
373 let source = BundleSource::parse("file:///home/user/bundle").unwrap();
374 assert!(matches!(source, BundleSource::FileUri(_)));
375 if let BundleSource::FileUri(path) = source {
376 assert_eq!(path, PathBuf::from("/home/user/bundle"));
377 }
378 }
379
380 #[cfg(feature = "oci")]
381 #[test]
382 fn parse_oci_reference() {
383 let source = BundleSource::parse("oci://ghcr.io/org/bundle:latest").unwrap();
384 assert!(matches!(source, BundleSource::Oci { .. }));
385 }
386
387 #[cfg(feature = "oci")]
388 #[test]
389 fn parse_repo_reference() {
390 let source = BundleSource::parse("repo://greentic/messaging-telegram").unwrap();
391 assert!(matches!(source, BundleSource::Repo { .. }));
392 }
393
394 #[cfg(feature = "oci")]
395 #[test]
396 fn parse_store_reference() {
397 let source = BundleSource::parse("store://bundle-abc123").unwrap();
398 assert!(matches!(source, BundleSource::Store { .. }));
399 }
400
401 #[test]
402 fn empty_source_fails() {
403 assert!(BundleSource::parse("").is_err());
404 assert!(BundleSource::parse(" ").is_err());
405 }
406
407 #[test]
408 fn file_uri_percent_decode() {
409 let decoded = percent_decode("path%20with%20spaces");
410 assert_eq!(decoded, "path with spaces");
411 }
412
413 #[test]
414 fn is_local_checks() {
415 let local = BundleSource::parse("./bundle").unwrap();
416 assert!(local.is_local());
417
418 let file_uri = BundleSource::parse("file:///path").unwrap();
419 assert!(file_uri.is_local());
420 }
421
422 #[cfg(feature = "oci")]
423 #[test]
424 fn is_remote_checks() {
425 let oci = BundleSource::parse("oci://ghcr.io/test").unwrap();
426 assert!(oci.is_remote());
427 assert!(!oci.is_local());
428 }
429
430 #[cfg(feature = "oci")]
431 #[test]
432 fn remote_references_preserve_original_strings() {
433 let refs = [
434 "oci://ghcr.io/greentic/example-pack:latest",
435 "repo://greentic/example-pack",
436 "store://greentic-biz/demo/example-pack:latest",
437 ];
438
439 for raw in refs {
440 let parsed = BundleSource::parse(raw).unwrap();
441 assert_eq!(parsed.as_str(), raw);
442 assert!(parsed.is_remote());
443 }
444 }
445}