Skip to main content

greentic_setup/
bundle_source.rs

1//! Bundle source resolution — parse and resolve bundle references from various protocols.
2//!
3//! Supports local paths, file:// URIs, and remote protocols via greentic-distributor-client.
4
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, anyhow};
8
9/// A bundle source that can be resolved to a local artifact path.
10#[derive(Clone, Debug)]
11pub enum BundleSource {
12    /// Local directory path (absolute or relative).
13    LocalDir(PathBuf),
14    /// file:// URI pointing to a local path.
15    FileUri(PathBuf),
16    /// oci://registry/repo:tag — OCI registry reference.
17    #[cfg(feature = "oci")]
18    Oci { reference: String },
19    /// repo://org/name — Pack repository reference (maps to OCI).
20    #[cfg(feature = "oci")]
21    Repo { reference: String },
22    /// store://id — Component store reference (maps to OCI).
23    #[cfg(feature = "oci")]
24    Store { reference: String },
25}
26
27impl BundleSource {
28    /// Parse a bundle source string into the appropriate variant.
29    ///
30    /// # Examples
31    ///
32    /// ```
33    /// use greentic_setup::bundle_source::BundleSource;
34    ///
35    /// // Local path
36    /// let source = BundleSource::parse("./my-bundle").unwrap();
37    ///
38    /// // file:// URI
39    /// let source = BundleSource::parse("file:///home/user/bundle").unwrap();
40    ///
41    /// // OCI reference (requires "oci" feature)
42    /// // let source = BundleSource::parse("oci://ghcr.io/org/bundle:latest").unwrap();
43    /// ```
44    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        // OCI protocol
52        #[cfg(feature = "oci")]
53        if trimmed.starts_with("oci://") {
54            return Ok(Self::Oci {
55                reference: trimmed.to_string(),
56            });
57        }
58
59        // Repo protocol
60        #[cfg(feature = "oci")]
61        if trimmed.starts_with("repo://") {
62            return Ok(Self::Repo {
63                reference: trimmed.to_string(),
64            });
65        }
66
67        // Store protocol
68        #[cfg(feature = "oci")]
69        if trimmed.starts_with("store://") {
70            return Ok(Self::Store {
71                reference: trimmed.to_string(),
72            });
73        }
74
75        // file:// URI
76        if trimmed.starts_with("file://") {
77            let path = file_uri_to_path(trimmed)?;
78            return Ok(Self::FileUri(path));
79        }
80
81        // Check for unsupported protocols
82        #[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        // Treat as local path
94        let path = PathBuf::from(trimmed);
95        Ok(Self::LocalDir(path))
96    }
97
98    /// Resolve the source to a local artifact path.
99    ///
100    /// For local sources, validates the path exists.
101    /// For remote sources, fetches and extracts to a local cache directory.
102    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    /// Resolve the source asynchronously.
116    ///
117    /// For local sources, validates the path exists.
118    /// For remote sources, fetches and extracts to a local cache directory.
119    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    /// Returns the original source string representation.
133    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    /// Returns true if this is a local source (LocalDir or FileUri).
147    pub fn is_local(&self) -> bool {
148        matches!(self, Self::LocalDir(_) | Self::FileUri(_))
149    }
150
151    /// Returns true if this is a remote source (Oci, Repo, or Store).
152    #[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
161/// Convert a file:// URI to a local path.
162fn 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    // Handle Windows paths (file:///C:/path)
168    #[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
175/// Simple percent-decoding for file paths.
176fn 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
199/// Resolve a local path, validating it exists.
200fn 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/// Resolve an OCI pack reference using the pack fetcher.
220#[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/// Resolve an OCI pack reference asynchronously.
229#[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/// Resolve a repo/store reference using greentic-distributor-client.
309#[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/// Resolve a repo/store reference asynchronously.
318#[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}