1use 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 use std::fs;
359 use std::sync::{Mutex, OnceLock};
360
361 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
362 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
363 LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
364 }
365
366 #[test]
367 fn parse_local_path() {
368 let source = BundleSource::parse("./my-bundle").unwrap();
369 assert!(matches!(source, BundleSource::LocalDir(_)));
370 }
371
372 #[test]
373 fn parse_absolute_path() {
374 let source = BundleSource::parse("/home/user/bundle").unwrap();
375 assert!(matches!(source, BundleSource::LocalDir(_)));
376 }
377
378 #[test]
379 fn parse_file_uri() {
380 let source = BundleSource::parse("file:///home/user/bundle").unwrap();
381 assert!(matches!(source, BundleSource::FileUri(_)));
382 if let BundleSource::FileUri(path) = source {
383 assert_eq!(path, PathBuf::from("/home/user/bundle"));
384 }
385 }
386
387 #[cfg(feature = "oci")]
388 #[test]
389 fn parse_oci_reference() {
390 let source = BundleSource::parse("oci://ghcr.io/org/bundle:latest").unwrap();
391 assert!(matches!(source, BundleSource::Oci { .. }));
392 }
393
394 #[cfg(feature = "oci")]
395 #[test]
396 fn parse_repo_reference() {
397 let source = BundleSource::parse("repo://greentic/messaging-telegram").unwrap();
398 assert!(matches!(source, BundleSource::Repo { .. }));
399 }
400
401 #[cfg(feature = "oci")]
402 #[test]
403 fn parse_store_reference() {
404 let source = BundleSource::parse("store://bundle-abc123").unwrap();
405 assert!(matches!(source, BundleSource::Store { .. }));
406 }
407
408 #[test]
409 fn empty_source_fails() {
410 assert!(BundleSource::parse("").is_err());
411 assert!(BundleSource::parse(" ").is_err());
412 }
413
414 #[test]
415 fn file_uri_percent_decode() {
416 let decoded = percent_decode("path%20with%20spaces");
417 assert_eq!(decoded, "path with spaces");
418 }
419
420 #[test]
421 fn percent_decode_preserves_invalid_sequences() {
422 let decoded = percent_decode("path%2G%tail%");
423 assert_eq!(decoded, "path%2G%tail%");
424 }
425
426 #[test]
427 fn as_str_preserves_local_and_file_uri_sources() {
428 let local = BundleSource::parse("./bundle").unwrap();
429 assert_eq!(local.as_str(), "./bundle");
430
431 let file_uri = BundleSource::parse("file:///tmp/test%20bundle").unwrap();
432 assert_eq!(file_uri.as_str(), "file:///tmp/test bundle");
433 }
434
435 #[test]
436 fn is_local_checks() {
437 let local = BundleSource::parse("./bundle").unwrap();
438 assert!(local.is_local());
439
440 let file_uri = BundleSource::parse("file:///path").unwrap();
441 assert!(file_uri.is_local());
442 }
443
444 #[cfg(feature = "oci")]
445 #[test]
446 fn is_remote_checks() {
447 let oci = BundleSource::parse("oci://ghcr.io/test").unwrap();
448 assert!(oci.is_remote());
449 assert!(!oci.is_local());
450 }
451
452 #[cfg(feature = "oci")]
453 #[test]
454 fn remote_references_preserve_original_strings() {
455 let refs = [
456 "oci://ghcr.io/greentic/example-pack:latest",
457 "repo://greentic/example-pack",
458 "store://greentic-biz/demo/example-pack:latest",
459 ];
460
461 for raw in refs {
462 let parsed = BundleSource::parse(raw).unwrap();
463 assert_eq!(parsed.as_str(), raw);
464 assert!(parsed.is_remote());
465 }
466 }
467
468 #[cfg(feature = "oci")]
469 #[test]
470 fn registry_basic_auth_uses_generic_oci_credentials_first() {
471 let _guard = env_lock();
472 unsafe {
473 std::env::set_var("OCI_USERNAME", "oci-user");
474 std::env::set_var("OCI_PASSWORD", "oci-pass");
475 std::env::remove_var("GHCR_TOKEN");
476 std::env::remove_var("GITHUB_TOKEN");
477 std::env::remove_var("GHCR_USERNAME");
478 std::env::remove_var("GHCR_USER");
479 std::env::remove_var("GITHUB_ACTOR");
480 std::env::remove_var("USER");
481 }
482
483 let creds = registry_basic_auth_for_reference("ghcr.io/greentic/example-pack:latest");
484 assert_eq!(
485 creds,
486 Some(("oci-user".to_string(), "oci-pass".to_string()))
487 );
488
489 unsafe {
490 std::env::remove_var("OCI_USERNAME");
491 std::env::remove_var("OCI_PASSWORD");
492 }
493 }
494
495 #[cfg(feature = "oci")]
496 #[test]
497 fn registry_basic_auth_builds_ghcr_credentials_from_github_env() {
498 let _guard = env_lock();
499 unsafe {
500 std::env::remove_var("OCI_USERNAME");
501 std::env::remove_var("OCI_PASSWORD");
502 std::env::set_var("GITHUB_TOKEN", "gh-token");
503 std::env::set_var("GITHUB_ACTOR", "octocat");
504 std::env::remove_var("GHCR_TOKEN");
505 std::env::remove_var("GHCR_USERNAME");
506 std::env::remove_var("GHCR_USER");
507 std::env::remove_var("USER");
508 }
509
510 let creds = registry_basic_auth_for_reference("ghcr.io/greentic/example-pack:latest");
511 assert_eq!(creds, Some(("octocat".to_string(), "gh-token".to_string())));
512
513 unsafe {
514 std::env::remove_var("GITHUB_TOKEN");
515 std::env::remove_var("GITHUB_ACTOR");
516 }
517 }
518
519 #[cfg(feature = "oci")]
520 #[test]
521 fn registry_basic_auth_returns_none_without_matching_env() {
522 let _guard = env_lock();
523 unsafe {
524 std::env::remove_var("OCI_USERNAME");
525 std::env::remove_var("OCI_PASSWORD");
526 std::env::remove_var("GHCR_TOKEN");
527 std::env::remove_var("GITHUB_TOKEN");
528 std::env::remove_var("GHCR_USERNAME");
529 std::env::remove_var("GHCR_USER");
530 std::env::remove_var("GITHUB_ACTOR");
531 std::env::remove_var("USER");
532 }
533
534 assert_eq!(
535 registry_basic_auth_for_reference("example.com/greentic/example-pack:latest"),
536 None
537 );
538 }
539
540 #[test]
541 fn resolve_local_dir_returns_relative_path_when_it_exists() {
542 let temp = tempfile::tempdir().unwrap();
543 let bundle = temp.path().join("bundle");
544 fs::create_dir_all(&bundle).unwrap();
545 let relative = bundle.strip_prefix(std::env::current_dir().unwrap()).ok();
546
547 if let Some(relative) = relative {
548 let source = BundleSource::LocalDir(relative.to_path_buf());
549 assert_eq!(source.resolve().unwrap(), relative);
550 return;
551 }
552
553 let source = BundleSource::LocalDir(bundle.clone());
554 assert_eq!(source.resolve().unwrap(), bundle);
555 }
556
557 #[test]
558 fn resolve_file_uri_returns_existing_absolute_path() {
559 let temp = tempfile::tempdir().unwrap();
560 let bundle = temp.path().join("bundle");
561 fs::create_dir_all(&bundle).unwrap();
562
563 let source = BundleSource::FileUri(bundle.clone());
564 assert_eq!(source.resolve().unwrap(), bundle);
565 }
566
567 #[tokio::test]
568 async fn resolve_async_supports_local_sources() {
569 let temp = tempfile::tempdir().unwrap();
570 let bundle = temp.path().join("bundle");
571 fs::create_dir_all(&bundle).unwrap();
572
573 let local = BundleSource::LocalDir(bundle.clone());
574 assert_eq!(local.resolve_async().await.unwrap(), bundle);
575
576 let file_uri = BundleSource::FileUri(bundle.clone());
577 assert_eq!(file_uri.resolve_async().await.unwrap(), bundle);
578 }
579
580 #[test]
581 fn resolve_missing_local_path_fails() {
582 let temp = tempfile::tempdir().unwrap();
583 let missing = temp.path().join("missing");
584 let source = BundleSource::LocalDir(missing.clone());
585
586 let error = source.resolve().unwrap_err().to_string();
587 assert!(error.contains("bundle path does not exist"));
588 assert!(error.contains(&missing.display().to_string()));
589 }
590}