Skip to main content

greentic_setup/
discovery.rs

1//! Pack discovery — scans a bundle directory for `.gtpack` files across
2//! provider domains (messaging, events, oauth) and extracts metadata.
3
4use std::path::{Path, PathBuf};
5
6use serde::Serialize;
7use serde_cbor::Value as CborValue;
8use zip::result::ZipError;
9
10/// Result of discovering packs in a bundle.
11#[derive(Clone, Debug, Serialize)]
12pub struct DiscoveryResult {
13    pub domains: DetectedDomains,
14    pub providers: Vec<DetectedProvider>,
15}
16
17/// Flags indicating which domains are present in the bundle.
18#[derive(Clone, Debug, Serialize)]
19pub struct DetectedDomains {
20    pub messaging: bool,
21    pub events: bool,
22    pub oauth: bool,
23    pub state: bool,
24    pub secrets: bool,
25}
26
27/// Metadata for a discovered provider pack.
28#[derive(Clone, Debug, Serialize)]
29pub struct DetectedProvider {
30    pub provider_id: String,
31    pub display_name: Option<String>,
32    pub domain: String,
33    pub pack_path: PathBuf,
34    pub id_source: ProviderIdSource,
35}
36
37/// How the provider ID was determined.
38#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
39#[serde(rename_all = "lowercase")]
40pub enum ProviderIdSource {
41    Manifest,
42    Filename,
43}
44
45/// Metadata extracted from a pack manifest.
46struct PackMeta {
47    pack_id: String,
48    display_name: Option<String>,
49}
50
51/// Options for discovery.
52#[derive(Default)]
53pub struct DiscoveryOptions {
54    /// Require CBOR manifests (no JSON fallback).
55    pub cbor_only: bool,
56}
57
58/// Well-known provider domain directories.
59const DOMAIN_DIRS: &[(&str, &str)] = &[
60    ("messaging", "providers/messaging"),
61    ("events", "providers/events"),
62    ("oauth", "providers/oauth"),
63    ("state", "providers/state"),
64    ("secrets", "providers/secrets"),
65];
66
67/// Discover provider packs in a bundle root directory.
68pub fn discover(root: &Path) -> anyhow::Result<DiscoveryResult> {
69    discover_with_options(root, DiscoveryOptions::default())
70}
71
72/// Discover provider packs with custom options.
73pub fn discover_with_options(
74    root: &Path,
75    options: DiscoveryOptions,
76) -> anyhow::Result<DiscoveryResult> {
77    let mut providers = Vec::new();
78
79    for &(domain, dir) in DOMAIN_DIRS {
80        let providers_dir = root.join(dir);
81        if !providers_dir.exists() {
82            continue;
83        }
84        for entry in std::fs::read_dir(&providers_dir)? {
85            let entry = entry?;
86            if !entry.file_type()?.is_file() {
87                continue;
88            }
89            let path = entry.path();
90            if path.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
91                continue;
92            }
93
94            let (provider_id, display_name, id_source) = if options.cbor_only {
95                match read_pack_meta_cbor_only(&path)? {
96                    Some(meta) => (meta.pack_id, meta.display_name, ProviderIdSource::Manifest),
97                    None => return Err(missing_cbor_error(&path)),
98                }
99            } else {
100                match read_pack_meta_from_manifest(&path)? {
101                    Some(meta) => (meta.pack_id, meta.display_name, ProviderIdSource::Manifest),
102                    None => {
103                        let stem = path
104                            .file_stem()
105                            .and_then(|v| v.to_str())
106                            .unwrap_or_default()
107                            .to_string();
108                        (stem, None, ProviderIdSource::Filename)
109                    }
110                }
111            };
112
113            providers.push(DetectedProvider {
114                provider_id,
115                display_name,
116                domain: domain.to_string(),
117                pack_path: path,
118                id_source,
119            });
120        }
121    }
122
123    providers.sort_by(|a, b| a.pack_path.cmp(&b.pack_path));
124
125    let domains = DetectedDomains {
126        messaging: providers.iter().any(|p| p.domain == "messaging"),
127        events: providers.iter().any(|p| p.domain == "events"),
128        oauth: providers.iter().any(|p| p.domain == "oauth"),
129        state: providers.iter().any(|p| p.domain == "state"),
130        secrets: providers.iter().any(|p| p.domain == "secrets"),
131    };
132
133    Ok(DiscoveryResult { domains, providers })
134}
135
136/// Persist discovery results to JSON files in the bundle's runtime state directory.
137pub fn persist(root: &Path, tenant: &str, discovery: &DiscoveryResult) -> anyhow::Result<()> {
138    let runtime_root = root.join("state").join("runtime").join(tenant);
139    std::fs::create_dir_all(&runtime_root)?;
140    let domains_path = runtime_root.join("detected_domains.json");
141    let providers_path = runtime_root.join("detected_providers.json");
142    write_json(&domains_path, &discovery.domains)?;
143    write_json(&providers_path, &discovery.providers)?;
144    Ok(())
145}
146
147fn write_json<T: Serialize>(path: &Path, value: &T) -> anyhow::Result<()> {
148    if let Some(parent) = path.parent() {
149        std::fs::create_dir_all(parent)?;
150    }
151    let payload = serde_json::to_string_pretty(value)?;
152    std::fs::write(path, payload)?;
153    Ok(())
154}
155
156// ── Manifest reading ────────────────────────────────────────────────────────
157
158fn read_pack_meta_from_manifest(path: &Path) -> anyhow::Result<Option<PackMeta>> {
159    let file = std::fs::File::open(path)?;
160    match zip::ZipArchive::new(file) {
161        Ok(mut archive) => {
162            if let Some(meta) = read_manifest_cbor(&mut archive)? {
163                return Ok(Some(meta));
164            }
165            if let Some(meta) = read_manifest_json(&mut archive, "pack.manifest.json")? {
166                return Ok(Some(meta));
167            }
168        }
169        Err(_) => {
170            if let Some(meta) = read_manifest_cbor_from_tar(path)? {
171                return Ok(Some(meta));
172            }
173        }
174    }
175    Ok(None)
176}
177
178fn read_pack_meta_cbor_only(path: &Path) -> anyhow::Result<Option<PackMeta>> {
179    let file = std::fs::File::open(path)?;
180    match zip::ZipArchive::new(file) {
181        Ok(mut archive) => read_manifest_cbor(&mut archive),
182        Err(_) => read_manifest_cbor_from_tar(path),
183    }
184}
185
186fn read_manifest_cbor(
187    archive: &mut zip::ZipArchive<std::fs::File>,
188) -> anyhow::Result<Option<PackMeta>> {
189    let mut file = match archive.by_name("manifest.cbor") {
190        Ok(file) => file,
191        Err(ZipError::FileNotFound) => return Ok(None),
192        Err(err) => return Err(err.into()),
193    };
194    let mut bytes = Vec::new();
195    std::io::Read::read_to_end(&mut file, &mut bytes)?;
196    let value: CborValue = serde_cbor::from_slice(&bytes)?;
197    extract_pack_meta_from_cbor(&value)
198}
199
200fn read_manifest_json(
201    archive: &mut zip::ZipArchive<std::fs::File>,
202    name: &str,
203) -> anyhow::Result<Option<PackMeta>> {
204    let mut file = match archive.by_name(name) {
205        Ok(file) => file,
206        Err(ZipError::FileNotFound) => return Ok(None),
207        Err(err) => return Err(err.into()),
208    };
209    let mut contents = String::new();
210    std::io::Read::read_to_string(&mut file, &mut contents)?;
211    let parsed: serde_json::Value = serde_json::from_str(&contents)?;
212
213    let resolve_dn = |obj: &serde_json::Value| -> Option<String> {
214        obj.get("display_name")
215            .and_then(|v| v.as_str())
216            .or_else(|| obj.get("name").and_then(|v| v.as_str()))
217            .map(String::from)
218    };
219
220    let display_name = resolve_dn(&parsed);
221
222    if let Some(id) = parsed.get("pack_id").and_then(|v| v.as_str()) {
223        return Ok(Some(PackMeta {
224            pack_id: id.to_string(),
225            display_name,
226        }));
227    }
228    if let Some(meta) = parsed.get("meta")
229        && let Some(id) = meta.get("pack_id").and_then(|v| v.as_str())
230    {
231        let dn = resolve_dn(meta).or(display_name);
232        return Ok(Some(PackMeta {
233            pack_id: id.to_string(),
234            display_name: dn,
235        }));
236    }
237    Ok(None)
238}
239
240fn read_manifest_cbor_from_tar(path: &Path) -> anyhow::Result<Option<PackMeta>> {
241    let file = std::fs::File::open(path)?;
242    let mut archive = tar::Archive::new(file);
243    for entry in archive.entries()? {
244        let mut entry = entry?;
245        if entry.path()?.as_ref() != Path::new("manifest.cbor") {
246            continue;
247        }
248        let mut bytes = Vec::new();
249        std::io::Read::read_to_end(&mut entry, &mut bytes)?;
250        let value: CborValue = serde_cbor::from_slice(&bytes)?;
251        return extract_pack_meta_from_cbor(&value);
252    }
253    Ok(None)
254}
255
256fn extract_pack_meta_from_cbor(value: &CborValue) -> anyhow::Result<Option<PackMeta>> {
257    let CborValue::Map(map) = value else {
258        return Ok(None);
259    };
260    let symbols = match map_get(map, "symbols") {
261        Some(CborValue::Map(map)) => Some(map),
262        _ => None,
263    };
264
265    let resolve_display_name =
266        |source_map: &std::collections::BTreeMap<CborValue, CborValue>| -> Option<String> {
267            map_get(source_map, "display_name")
268                .and_then(|v| match v {
269                    CborValue::Text(text) => Some(text.clone()),
270                    _ => resolve_string_symbol(v, symbols, "display_names")
271                        .ok()
272                        .flatten(),
273                })
274                .or_else(|| {
275                    map_get(source_map, "name").and_then(|v| match v {
276                        CborValue::Text(text) => Some(text.clone()),
277                        _ => resolve_string_symbol(v, symbols, "names").ok().flatten(),
278                    })
279                })
280        };
281
282    if let Some(pack_id) = map_get(map, "pack_id")
283        && let Some(id) = resolve_string_symbol(pack_id, symbols, "pack_ids")?
284    {
285        return Ok(Some(PackMeta {
286            pack_id: id,
287            display_name: resolve_display_name(map),
288        }));
289    }
290
291    if let Some(CborValue::Map(meta)) = map_get(map, "meta")
292        && let Some(pack_id) = map_get(meta, "pack_id")
293        && let Some(id) = resolve_string_symbol(pack_id, symbols, "pack_ids")?
294    {
295        return Ok(Some(PackMeta {
296            pack_id: id,
297            display_name: resolve_display_name(meta).or_else(|| resolve_display_name(map)),
298        }));
299    }
300
301    Ok(None)
302}
303
304fn resolve_string_symbol(
305    value: &CborValue,
306    symbols: Option<&std::collections::BTreeMap<CborValue, CborValue>>,
307    symbol_key: &str,
308) -> anyhow::Result<Option<String>> {
309    match value {
310        CborValue::Text(text) => Ok(Some(text.clone())),
311        CborValue::Integer(idx) => {
312            let Some(symbols) = symbols else {
313                return Ok(Some(idx.to_string()));
314            };
315            let Some(CborValue::Array(values)) = map_get(symbols, symbol_key)
316                .or_else(|| map_get(symbols, symbol_key.strip_suffix('s').unwrap_or(symbol_key)))
317            else {
318                return Ok(Some(idx.to_string()));
319            };
320            let idx = usize::try_from(*idx).unwrap_or(usize::MAX);
321            match values.get(idx) {
322                Some(CborValue::Text(text)) => Ok(Some(text.clone())),
323                _ => Ok(Some(idx.to_string())),
324            }
325        }
326        _ => Ok(None),
327    }
328}
329
330fn map_get<'a>(
331    map: &'a std::collections::BTreeMap<CborValue, CborValue>,
332    key: &str,
333) -> Option<&'a CborValue> {
334    map.iter().find_map(|(k, v)| match k {
335        CborValue::Text(text) if text == key => Some(v),
336        _ => None,
337    })
338}
339
340fn missing_cbor_error(path: &Path) -> anyhow::Error {
341    anyhow::anyhow!(
342        "demo packs must be CBOR-only (.gtpack must contain manifest.cbor). \
343         Rebuild the pack with greentic-pack build (do not use --dev). Missing in {}",
344        path.display()
345    )
346}