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