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 directory 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 directory 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_reference(reference),
108            #[cfg(feature = "oci")]
109            Self::Repo { reference } => resolve_oci_reference(reference),
110            #[cfg(feature = "oci")]
111            Self::Store { reference } => resolve_oci_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_reference_async(reference).await,
125            #[cfg(feature = "oci")]
126            Self::Repo { reference } => resolve_oci_reference_async(reference).await,
127            #[cfg(feature = "oci")]
128            Self::Store { reference } => resolve_oci_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/repo/store reference using greentic-distributor-client.
220#[cfg(feature = "oci")]
221fn resolve_oci_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_reference_async(reference))
226}
227
228/// Resolve an OCI/repo/store reference asynchronously.
229#[cfg(feature = "oci")]
230async fn resolve_oci_reference_async(reference: &str) -> anyhow::Result<PathBuf> {
231    use greentic_distributor_client::oci_packs::fetch_pack_to_cache;
232
233    let resolved = fetch_pack_to_cache(reference)
234        .await
235        .with_context(|| format!("failed to resolve bundle reference: {}", reference))?;
236
237    // The resolved artifact contains a path to the cached content
238    Ok(resolved.path)
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn parse_local_path() {
247        let source = BundleSource::parse("./my-bundle").unwrap();
248        assert!(matches!(source, BundleSource::LocalDir(_)));
249    }
250
251    #[test]
252    fn parse_absolute_path() {
253        let source = BundleSource::parse("/home/user/bundle").unwrap();
254        assert!(matches!(source, BundleSource::LocalDir(_)));
255    }
256
257    #[test]
258    fn parse_file_uri() {
259        let source = BundleSource::parse("file:///home/user/bundle").unwrap();
260        assert!(matches!(source, BundleSource::FileUri(_)));
261        if let BundleSource::FileUri(path) = source {
262            assert_eq!(path, PathBuf::from("/home/user/bundle"));
263        }
264    }
265
266    #[cfg(feature = "oci")]
267    #[test]
268    fn parse_oci_reference() {
269        let source = BundleSource::parse("oci://ghcr.io/org/bundle:latest").unwrap();
270        assert!(matches!(source, BundleSource::Oci { .. }));
271    }
272
273    #[cfg(feature = "oci")]
274    #[test]
275    fn parse_repo_reference() {
276        let source = BundleSource::parse("repo://greentic/messaging-telegram").unwrap();
277        assert!(matches!(source, BundleSource::Repo { .. }));
278    }
279
280    #[cfg(feature = "oci")]
281    #[test]
282    fn parse_store_reference() {
283        let source = BundleSource::parse("store://bundle-abc123").unwrap();
284        assert!(matches!(source, BundleSource::Store { .. }));
285    }
286
287    #[test]
288    fn empty_source_fails() {
289        assert!(BundleSource::parse("").is_err());
290        assert!(BundleSource::parse("   ").is_err());
291    }
292
293    #[test]
294    fn file_uri_percent_decode() {
295        let decoded = percent_decode("path%20with%20spaces");
296        assert_eq!(decoded, "path with spaces");
297    }
298
299    #[test]
300    fn is_local_checks() {
301        let local = BundleSource::parse("./bundle").unwrap();
302        assert!(local.is_local());
303
304        let file_uri = BundleSource::parse("file:///path").unwrap();
305        assert!(file_uri.is_local());
306    }
307
308    #[cfg(feature = "oci")]
309    #[test]
310    fn is_remote_checks() {
311        let oci = BundleSource::parse("oci://ghcr.io/test").unwrap();
312        assert!(oci.is_remote());
313        assert!(!oci.is_local());
314    }
315}