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());
}
}
}