use std::path::{Path, PathBuf};
use anyhow::{Context, anyhow};
#[derive(Clone, Debug)]
pub enum BundleSource {
LocalDir(PathBuf),
FileUri(PathBuf),
#[cfg(feature = "oci")]
Oci { reference: String },
#[cfg(feature = "oci")]
Repo { reference: String },
#[cfg(feature = "oci")]
Store { reference: String },
}
impl BundleSource {
pub fn parse(source: &str) -> anyhow::Result<Self> {
let trimmed = source.trim();
if trimmed.is_empty() {
return Err(anyhow!("bundle source cannot be empty"));
}
#[cfg(feature = "oci")]
if trimmed.starts_with("oci://") {
return Ok(Self::Oci {
reference: trimmed.to_string(),
});
}
#[cfg(feature = "oci")]
if trimmed.starts_with("repo://") {
return Ok(Self::Repo {
reference: trimmed.to_string(),
});
}
#[cfg(feature = "oci")]
if trimmed.starts_with("store://") {
return Ok(Self::Store {
reference: trimmed.to_string(),
});
}
if trimmed.starts_with("file://") {
let path = file_uri_to_path(trimmed)?;
return Ok(Self::FileUri(path));
}
#[cfg(not(feature = "oci"))]
if trimmed.starts_with("oci://")
|| trimmed.starts_with("repo://")
|| trimmed.starts_with("store://")
{
return Err(anyhow!(
"protocol not supported (compile with 'oci' feature): {}",
trimmed.split("://").next().unwrap_or("unknown")
));
}
let path = PathBuf::from(trimmed);
Ok(Self::LocalDir(path))
}
pub fn resolve(&self) -> anyhow::Result<PathBuf> {
match self {
Self::LocalDir(path) => resolve_local_path(path),
Self::FileUri(path) => resolve_local_path(path),
#[cfg(feature = "oci")]
Self::Oci { reference } => resolve_oci_pack_reference(reference),
#[cfg(feature = "oci")]
Self::Repo { reference } => resolve_distributor_reference(reference),
#[cfg(feature = "oci")]
Self::Store { reference } => resolve_distributor_reference(reference),
}
}
pub async fn resolve_async(&self) -> anyhow::Result<PathBuf> {
match self {
Self::LocalDir(path) => resolve_local_path(path),
Self::FileUri(path) => resolve_local_path(path),
#[cfg(feature = "oci")]
Self::Oci { reference } => resolve_oci_pack_reference_async(reference).await,
#[cfg(feature = "oci")]
Self::Repo { reference } => resolve_distributor_reference_async(reference).await,
#[cfg(feature = "oci")]
Self::Store { reference } => resolve_distributor_reference_async(reference).await,
}
}
pub fn as_str(&self) -> String {
match self {
Self::LocalDir(path) => path.display().to_string(),
Self::FileUri(path) => format!("file://{}", path.display()),
#[cfg(feature = "oci")]
Self::Oci { reference } => reference.clone(),
#[cfg(feature = "oci")]
Self::Repo { reference } => reference.clone(),
#[cfg(feature = "oci")]
Self::Store { reference } => reference.clone(),
}
}
pub fn is_local(&self) -> bool {
matches!(self, Self::LocalDir(_) | Self::FileUri(_))
}
#[cfg(feature = "oci")]
pub fn is_remote(&self) -> bool {
matches!(
self,
Self::Oci { .. } | Self::Repo { .. } | Self::Store { .. }
)
}
}
fn file_uri_to_path(uri: &str) -> anyhow::Result<PathBuf> {
let path_str = uri
.strip_prefix("file://")
.ok_or_else(|| anyhow!("invalid file URI: {}", uri))?;
#[cfg(windows)]
let path_str = path_str.strip_prefix('/').unwrap_or(path_str);
let decoded = percent_decode(path_str);
Ok(PathBuf::from(decoded))
}
fn percent_decode(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '%' {
let hex: String = chars.by_ref().take(2).collect();
if hex.len() == 2
&& let Ok(byte) = u8::from_str_radix(&hex, 16)
{
result.push(byte as char);
continue;
}
result.push('%');
result.push_str(&hex);
} else {
result.push(ch);
}
}
result
}
fn resolve_local_path(path: &Path) -> anyhow::Result<PathBuf> {
let canonical = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.context("failed to get current directory")?
.join(path)
};
if !canonical.exists() {
return Err(anyhow!(
"bundle path does not exist: {}",
canonical.display()
));
}
Ok(canonical)
}
#[cfg(feature = "oci")]
fn resolve_oci_pack_reference(reference: &str) -> anyhow::Result<PathBuf> {
use tokio::runtime::Runtime;
let rt = Runtime::new().context("failed to create tokio runtime")?;
rt.block_on(resolve_oci_pack_reference_async(reference))
}
#[cfg(feature = "oci")]
async fn resolve_oci_pack_reference_async(reference: &str) -> anyhow::Result<PathBuf> {
use greentic_distributor_client::oci_packs::DefaultRegistryClient;
use greentic_distributor_client::{OciPackFetcher, PackFetchOptions};
let oci_reference = reference.strip_prefix("oci://").unwrap_or(reference).trim();
let options = PackFetchOptions {
allow_tags: true,
..PackFetchOptions::default()
};
let fetched =
if let Some((username, password)) = registry_basic_auth_for_reference(oci_reference) {
let client = DefaultRegistryClient::with_basic_auth(username, password);
OciPackFetcher::with_client(client, options)
.fetch_pack_to_cache(oci_reference)
.await
} else {
OciPackFetcher::<DefaultRegistryClient>::new(options)
.fetch_pack_to_cache(oci_reference)
.await
}
.with_context(|| format!("failed to fetch OCI pack reference: {}", reference))?;
if fetched.path.exists() {
return Ok(fetched.path);
}
anyhow::bail!(
"resolved bundle reference without a local cached artifact: {}",
reference
);
}
#[cfg(feature = "oci")]
fn registry_basic_auth_for_reference(reference: &str) -> Option<(String, String)> {
let registry = reference.split('/').next().unwrap_or_default();
let generic_username = std::env::var("OCI_USERNAME")
.ok()
.filter(|value| !value.is_empty());
let generic_password = std::env::var("OCI_PASSWORD")
.ok()
.filter(|value| !value.is_empty());
if let (Some(username), Some(password)) = (generic_username, generic_password) {
return Some((username, password));
}
if registry == "ghcr.io" {
let password = std::env::var("GHCR_TOKEN")
.ok()
.filter(|value| !value.is_empty())
.or_else(|| {
std::env::var("GITHUB_TOKEN")
.ok()
.filter(|value| !value.is_empty())
});
let username = std::env::var("GHCR_USERNAME")
.ok()
.filter(|value| !value.is_empty())
.or_else(|| {
std::env::var("GHCR_USER")
.ok()
.filter(|value| !value.is_empty())
})
.or_else(|| {
std::env::var("GITHUB_ACTOR")
.ok()
.filter(|value| !value.is_empty())
})
.or_else(|| std::env::var("USER").ok().filter(|value| !value.is_empty()));
if let (Some(username), Some(password)) = (username, password) {
return Some((username, password));
}
}
None
}
#[cfg(feature = "oci")]
fn resolve_distributor_reference(reference: &str) -> anyhow::Result<PathBuf> {
use tokio::runtime::Runtime;
let rt = Runtime::new().context("failed to create tokio runtime")?;
rt.block_on(resolve_distributor_reference_async(reference))
}
#[cfg(feature = "oci")]
async fn resolve_distributor_reference_async(reference: &str) -> anyhow::Result<PathBuf> {
use greentic_distributor_client::{CachePolicy, DistClient, DistOptions, ResolvePolicy};
let client = DistClient::new(DistOptions::default());
let source = client
.parse_source(reference)
.with_context(|| format!("failed to parse bundle reference: {}", reference))?;
let resolved = client
.resolve(source, ResolvePolicy)
.await
.with_context(|| format!("failed to resolve bundle reference: {}", reference))?;
let fetched = client
.fetch(&resolved, CachePolicy)
.await
.with_context(|| format!("failed to fetch bundle reference: {}", reference))?;
if fetched.local_path.exists() {
return Ok(fetched.local_path);
}
if let Some(path) = fetched.wasm_path
&& path.exists()
{
return Ok(path);
}
if let Some(path) = fetched.cache_path
&& path.exists()
{
return Ok(path);
}
anyhow::bail!(
"resolved bundle reference without a local cached artifact: {}",
reference
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_local_path() {
let source = BundleSource::parse("./my-bundle").unwrap();
assert!(matches!(source, BundleSource::LocalDir(_)));
}
#[test]
fn parse_absolute_path() {
let source = BundleSource::parse("/home/user/bundle").unwrap();
assert!(matches!(source, BundleSource::LocalDir(_)));
}
#[test]
fn parse_file_uri() {
let source = BundleSource::parse("file:///home/user/bundle").unwrap();
assert!(matches!(source, BundleSource::FileUri(_)));
if let BundleSource::FileUri(path) = source {
assert_eq!(path, PathBuf::from("/home/user/bundle"));
}
}
#[cfg(feature = "oci")]
#[test]
fn parse_oci_reference() {
let source = BundleSource::parse("oci://ghcr.io/org/bundle:latest").unwrap();
assert!(matches!(source, BundleSource::Oci { .. }));
}
#[cfg(feature = "oci")]
#[test]
fn parse_repo_reference() {
let source = BundleSource::parse("repo://greentic/messaging-telegram").unwrap();
assert!(matches!(source, BundleSource::Repo { .. }));
}
#[cfg(feature = "oci")]
#[test]
fn parse_store_reference() {
let source = BundleSource::parse("store://bundle-abc123").unwrap();
assert!(matches!(source, BundleSource::Store { .. }));
}
#[test]
fn empty_source_fails() {
assert!(BundleSource::parse("").is_err());
assert!(BundleSource::parse(" ").is_err());
}
#[test]
fn file_uri_percent_decode() {
let decoded = percent_decode("path%20with%20spaces");
assert_eq!(decoded, "path with spaces");
}
#[test]
fn is_local_checks() {
let local = BundleSource::parse("./bundle").unwrap();
assert!(local.is_local());
let file_uri = BundleSource::parse("file:///path").unwrap();
assert!(file_uri.is_local());
}
#[cfg(feature = "oci")]
#[test]
fn is_remote_checks() {
let oci = BundleSource::parse("oci://ghcr.io/test").unwrap();
assert!(oci.is_remote());
assert!(!oci.is_local());
}
#[cfg(feature = "oci")]
#[test]
fn remote_references_preserve_original_strings() {
let refs = [
"oci://ghcr.io/greentic/example-pack:latest",
"repo://greentic/example-pack",
"store://greentic-biz/demo/example-pack:latest",
];
for raw in refs {
let parsed = BundleSource::parse(raw).unwrap();
assert_eq!(parsed.as_str(), raw);
assert!(parsed.is_remote());
}
}
#[test]
fn parse_trims_whitespace() {
let source = BundleSource::parse(" ./bundle ").unwrap();
if let BundleSource::LocalDir(path) = source {
assert_eq!(path, PathBuf::from("./bundle"));
} else {
panic!("expected LocalDir variant");
}
}
#[test]
fn as_str_local_dir_returns_path() {
let source = BundleSource::LocalDir(PathBuf::from("/tmp/example"));
assert_eq!(source.as_str(), "/tmp/example");
}
#[test]
fn as_str_file_uri_prepends_scheme() {
let source = BundleSource::FileUri(PathBuf::from("/tmp/example"));
assert_eq!(source.as_str(), "file:///tmp/example");
}
#[test]
fn local_dir_is_not_remote_under_oci_feature() {
#[cfg(feature = "oci")]
{
let local = BundleSource::parse("./bundle").unwrap();
assert!(!local.is_remote());
}
}
#[test]
fn resolve_returns_canonical_path_for_existing_dir() {
let dir = tempfile::tempdir().unwrap();
let source = BundleSource::LocalDir(dir.path().to_path_buf());
let resolved = source.resolve().expect("resolve existing path");
assert!(resolved.exists());
}
#[test]
fn resolve_returns_error_for_missing_dir() {
let source = BundleSource::LocalDir(PathBuf::from("/nonexistent/path/9f8c7b6a"));
let err = source.resolve().unwrap_err();
assert!(err.to_string().contains("does not exist"));
}
#[test]
fn resolve_relative_path_joins_with_cwd() {
let dir = tempfile::tempdir().unwrap();
let parent = dir.path();
let child = parent.join("child");
std::fs::create_dir_all(&child).unwrap();
let original_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(parent).unwrap();
let source = BundleSource::LocalDir(PathBuf::from("./child"));
let result = source.resolve();
std::env::set_current_dir(original_cwd).unwrap();
let resolved = result.expect("resolve relative path");
assert!(resolved.ends_with("child"));
}
#[test]
fn resolve_file_uri_validates_path() {
let dir = tempfile::tempdir().unwrap();
let source = BundleSource::FileUri(dir.path().to_path_buf());
assert!(source.resolve().is_ok());
}
#[tokio::test(flavor = "current_thread")]
async fn resolve_async_local_dir_succeeds() {
let dir = tempfile::tempdir().unwrap();
let source = BundleSource::LocalDir(dir.path().to_path_buf());
let resolved = source.resolve_async().await.unwrap();
assert!(resolved.exists());
}
#[tokio::test(flavor = "current_thread")]
async fn resolve_async_file_uri_succeeds() {
let dir = tempfile::tempdir().unwrap();
let source = BundleSource::FileUri(dir.path().to_path_buf());
let resolved = source.resolve_async().await.unwrap();
assert!(resolved.exists());
}
#[tokio::test(flavor = "current_thread")]
async fn resolve_async_local_dir_missing_errors() {
let source = BundleSource::LocalDir(PathBuf::from("/nonexistent/async/9f8c7b6a"));
assert!(source.resolve_async().await.is_err());
}
#[test]
fn percent_decode_passes_through_invalid_escapes() {
let decoded = percent_decode("foo%ZZbar");
assert_eq!(decoded, "foo%ZZbar");
}
#[test]
fn percent_decode_handles_trailing_percent() {
let decoded = percent_decode("trailing%");
assert_eq!(decoded, "trailing%");
}
#[test]
fn percent_decode_handles_short_trailing_percent() {
let decoded = percent_decode("short%2");
assert_eq!(decoded, "short%2");
}
#[cfg(not(feature = "oci"))]
#[test]
fn unsupported_protocol_errors_without_oci_feature() {
for raw in ["oci://x/y:1", "repo://x/y", "store://abc"] {
let err = BundleSource::parse(raw).unwrap_err();
assert!(err.to_string().contains("not supported"));
}
}
#[cfg(feature = "oci")]
#[test]
fn registry_basic_auth_explores_env_var_branches() {
use std::sync::Mutex;
static LOCK: Mutex<()> = Mutex::new(());
let _guard = LOCK.lock().unwrap_or_else(|p| p.into_inner());
let keys = [
"OCI_USERNAME",
"OCI_PASSWORD",
"GHCR_TOKEN",
"GITHUB_TOKEN",
"GHCR_USERNAME",
"GHCR_USER",
"GITHUB_ACTOR",
"USER",
];
let saved: Vec<(&str, Option<String>)> =
keys.iter().map(|k| (*k, std::env::var(*k).ok())).collect();
unsafe {
for k in keys {
std::env::remove_var(k);
}
}
assert!(registry_basic_auth_for_reference("ghcr.io/example/pack:latest").is_none());
assert!(registry_basic_auth_for_reference("docker.io/example/pack").is_none());
unsafe {
std::env::set_var("OCI_USERNAME", "u-generic");
std::env::set_var("OCI_PASSWORD", "p-generic");
}
let (u, p) = registry_basic_auth_for_reference("docker.io/example/pack")
.expect("generic creds should resolve");
assert_eq!((u.as_str(), p.as_str()), ("u-generic", "p-generic"));
unsafe {
std::env::set_var("OCI_USERNAME", "");
std::env::set_var("OCI_PASSWORD", "");
std::env::set_var("GHCR_TOKEN", "ghcr-token");
std::env::set_var("GITHUB_ACTOR", "actor-user");
}
let (u, p) = registry_basic_auth_for_reference("ghcr.io/example/pack:latest")
.expect("ghcr fallback should resolve");
assert_eq!((u.as_str(), p.as_str()), ("actor-user", "ghcr-token"));
unsafe {
std::env::remove_var("OCI_USERNAME");
std::env::remove_var("OCI_PASSWORD");
}
assert!(registry_basic_auth_for_reference("docker.io/example/pack").is_none());
unsafe {
for (k, v) in &saved {
match v {
Some(value) => std::env::set_var(k, value),
None => std::env::remove_var(k),
}
}
}
}
}