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 base64::Engine;
7use serde::Serialize;
8use serde_cbor::Value as CborValue;
9use zip::result::ZipError;
10
11/// Result of discovering packs in a bundle.
12#[derive(Clone, Debug, Serialize)]
13pub struct DiscoveryResult {
14    pub domains: DetectedDomains,
15    pub providers: Vec<DetectedProvider>,
16    pub app_packs: Vec<DetectedProvider>,
17}
18
19/// Flags indicating which domains are present in the bundle.
20#[derive(Clone, Debug, Serialize)]
21pub struct DetectedDomains {
22    pub messaging: bool,
23    pub events: bool,
24    pub oauth: bool,
25    pub state: bool,
26    pub secrets: bool,
27}
28
29/// Metadata for a discovered provider pack.
30#[derive(Clone, Debug, Serialize)]
31pub struct DetectedProvider {
32    pub provider_id: String,
33    pub display_name: Option<String>,
34    pub domain: String,
35    pub pack_path: PathBuf,
36    pub id_source: ProviderIdSource,
37    pub kind: DetectedPackKind,
38}
39
40/// The broad role this pack plays inside the bundle.
41#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
42#[serde(rename_all = "snake_case")]
43pub enum DetectedPackKind {
44    Provider,
45    App,
46}
47
48/// How the provider ID was determined.
49#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
50#[serde(rename_all = "lowercase")]
51pub enum ProviderIdSource {
52    Manifest,
53    Filename,
54}
55
56/// Metadata extracted from a pack manifest.
57struct PackMeta {
58    pack_id: String,
59    display_name: Option<String>,
60}
61
62/// Public manifest metadata helper returned by [`read_pack_meta`].
63#[derive(Clone, Debug)]
64pub struct DiscoveredPackMeta {
65    pub pack_id: String,
66    pub display_name: Option<String>,
67}
68
69/// Options for discovery.
70#[derive(Default)]
71pub struct DiscoveryOptions {
72    /// Require CBOR manifests (no JSON fallback).
73    pub cbor_only: bool,
74}
75
76/// Well-known provider domain directories.
77const DOMAIN_DIRS: &[(&str, &str)] = &[
78    ("messaging", "providers/messaging"),
79    ("events", "providers/events"),
80    ("oauth", "providers/oauth"),
81    ("state", "providers/state"),
82    ("secrets", "providers/secrets"),
83];
84
85/// Discover provider packs in a bundle root directory.
86pub fn discover(root: &Path) -> anyhow::Result<DiscoveryResult> {
87    discover_with_options(root, DiscoveryOptions::default())
88}
89
90/// Discover provider packs with custom options.
91pub fn discover_with_options(
92    root: &Path,
93    options: DiscoveryOptions,
94) -> anyhow::Result<DiscoveryResult> {
95    let mut providers = Vec::new();
96
97    for &(domain, dir) in DOMAIN_DIRS {
98        let providers_dir = root.join(dir);
99        if !providers_dir.exists() {
100            continue;
101        }
102        for entry in std::fs::read_dir(&providers_dir)? {
103            let entry = entry?;
104            if !entry.file_type()?.is_file() {
105                continue;
106            }
107            let path = entry.path();
108            if path.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
109                continue;
110            }
111            let (provider_id, display_name, id_source) =
112                read_pack_identity(&path, options.cbor_only)?;
113            providers.push(DetectedProvider {
114                provider_id,
115                display_name,
116                domain: domain.to_string(),
117                pack_path: path,
118                id_source,
119                kind: DetectedPackKind::Provider,
120            });
121        }
122    }
123
124    // Extension providers land at `providers/<name>.gtpack[/inner.gtpack]`
125    // (depth 1), outside the DOMAIN_DIRS subdirs. Catch them here.
126    let providers_root = root.join("providers");
127    if providers_root.exists() {
128        let known_subdirs: std::collections::HashSet<&str> = DOMAIN_DIRS
129            .iter()
130            .filter_map(|(_, dir)| {
131                std::path::Path::new(dir)
132                    .file_name()
133                    .and_then(|name| name.to_str())
134            })
135            .collect();
136        for entry in std::fs::read_dir(&providers_root)? {
137            let entry = entry?;
138            let entry_path = entry.path();
139            let name_str = entry_path
140                .file_name()
141                .and_then(|s| s.to_str())
142                .unwrap_or_default();
143
144            // Accept both `providers/<name>.gtpack` (file) and the wrapper
145            // dir shape `providers/<name>.gtpack/<inner>.gtpack`.
146            let pack_path = if entry.file_type()?.is_file() {
147                if entry_path.extension().and_then(|e| e.to_str()) != Some("gtpack") {
148                    continue;
149                }
150                entry_path.clone()
151            } else if entry.file_type()?.is_dir() {
152                if known_subdirs.contains(name_str) {
153                    continue;
154                }
155                if !name_str.ends_with(".gtpack") {
156                    continue;
157                }
158                let inner = entry_path.join(name_str);
159                if inner.is_file() {
160                    inner
161                } else {
162                    // Fall back to the first `.gtpack` inside.
163                    match std::fs::read_dir(&entry_path)?
164                        .filter_map(|e| e.ok())
165                        .find(|e| e.path().extension().and_then(|x| x.to_str()) == Some("gtpack"))
166                        .map(|e| e.path())
167                    {
168                        Some(found) => found,
169                        None => continue,
170                    }
171                }
172            } else {
173                continue;
174            };
175
176            let (provider_id, display_name, id_source) =
177                read_pack_identity(&pack_path, options.cbor_only)?;
178
179            // Skip if DOMAIN_DIRS already picked up the same provider.
180            if providers.iter().any(|p| p.provider_id == provider_id) {
181                continue;
182            }
183            let domain = crate::cli_helpers::detect_domain_from_filename(
184                pack_path
185                    .file_name()
186                    .and_then(|s| s.to_str())
187                    .unwrap_or_default(),
188            )
189            .to_string();
190            providers.push(DetectedProvider {
191                provider_id,
192                display_name,
193                domain,
194                pack_path,
195                id_source,
196                kind: DetectedPackKind::Provider,
197            });
198        }
199    }
200
201    let mut app_packs = Vec::new();
202    let packs_dir = root.join("packs");
203    if packs_dir.exists() {
204        for entry in std::fs::read_dir(&packs_dir)? {
205            let entry = entry?;
206            if !entry.file_type()?.is_file() {
207                continue;
208            }
209            let path = entry.path();
210            if path.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
211                continue;
212            }
213            let (provider_id, display_name, id_source) =
214                read_pack_identity(&path, options.cbor_only)?;
215            app_packs.push(DetectedProvider {
216                provider_id,
217                display_name,
218                domain: "app".to_string(),
219                pack_path: path,
220                id_source,
221                kind: DetectedPackKind::App,
222            });
223        }
224    }
225
226    providers.sort_by(|a, b| a.pack_path.cmp(&b.pack_path));
227    app_packs.sort_by(|a, b| a.pack_path.cmp(&b.pack_path));
228
229    let domains = DetectedDomains {
230        messaging: providers.iter().any(|p| p.domain == "messaging"),
231        events: providers.iter().any(|p| p.domain == "events"),
232        oauth: providers.iter().any(|p| p.domain == "oauth"),
233        state: providers.iter().any(|p| p.domain == "state"),
234        secrets: providers.iter().any(|p| p.domain == "secrets"),
235    };
236
237    Ok(DiscoveryResult {
238        domains,
239        providers,
240        app_packs,
241    })
242}
243
244/// Persist discovery results to JSON files in the bundle's runtime state directory.
245pub fn persist(root: &Path, tenant: &str, discovery: &DiscoveryResult) -> anyhow::Result<()> {
246    let runtime_root = root.join("state").join("runtime").join(tenant);
247    std::fs::create_dir_all(&runtime_root)?;
248    let domains_path = runtime_root.join("detected_domains.json");
249    let providers_path = runtime_root.join("detected_providers.json");
250    let app_packs_path = runtime_root.join("detected_app_packs.json");
251    write_json(&domains_path, &discovery.domains)?;
252    write_json(&providers_path, &discovery.providers)?;
253    write_json(&app_packs_path, &discovery.app_packs)?;
254    Ok(())
255}
256
257impl DiscoveryResult {
258    /// Return every discovered pack that can participate in setup.
259    pub fn setup_targets(&self) -> Vec<&DetectedProvider> {
260        self.providers
261            .iter()
262            .chain(self.app_packs.iter())
263            .filter(|pack| pack.participates_in_setup())
264            .collect()
265    }
266
267    /// Find a discovered setup target by pack/provider ID.
268    pub fn find_setup_target(&self, provider_id: &str) -> Option<&DetectedProvider> {
269        self.providers
270            .iter()
271            .chain(self.app_packs.iter())
272            .find(|pack| pack.provider_id == provider_id && pack.participates_in_setup())
273    }
274}
275
276impl DetectedProvider {
277    fn participates_in_setup(&self) -> bool {
278        self.domain != "secrets"
279            && !crate::deployment_targets::is_deployer_pack_path(&self.pack_path)
280    }
281}
282
283/// Resolve `(provider_id, display_name, id_source)` for a `.gtpack` file,
284/// using the manifest when available and falling back to the filename stem.
285/// Honours `cbor_only` mode, which requires the manifest-derived metadata.
286fn read_pack_identity(
287    path: &Path,
288    cbor_only: bool,
289) -> anyhow::Result<(String, Option<String>, ProviderIdSource)> {
290    if cbor_only {
291        match read_pack_meta_cbor_only(path)? {
292            Some(meta) => Ok((meta.pack_id, meta.display_name, ProviderIdSource::Manifest)),
293            None => Err(missing_cbor_error(path)),
294        }
295    } else {
296        match read_pack_meta_from_manifest(path)? {
297            Some(meta) => Ok((meta.pack_id, meta.display_name, ProviderIdSource::Manifest)),
298            None => {
299                let stem = path
300                    .file_stem()
301                    .and_then(|v| v.to_str())
302                    .unwrap_or_default()
303                    .to_string();
304                Ok((stem, None, ProviderIdSource::Filename))
305            }
306        }
307    }
308}
309
310/// Read the pack ID and display name from a `.gtpack` manifest when available.
311pub fn read_pack_meta(path: &Path) -> anyhow::Result<Option<DiscoveredPackMeta>> {
312    read_pack_meta_from_manifest(path).map(|meta| {
313        meta.map(|meta| DiscoveredPackMeta {
314            pack_id: meta.pack_id,
315            display_name: meta.display_name,
316        })
317    })
318}
319
320/// Read a top-level manifest extension value by key from a `.gtpack`.
321pub fn read_pack_extension(
322    path: &Path,
323    extension_key: &str,
324) -> anyhow::Result<Option<serde_json::Value>> {
325    let file = std::fs::File::open(path)?;
326    match zip::ZipArchive::new(file) {
327        Ok(mut archive) => {
328            if let Some(value) = read_manifest_extension_cbor(&mut archive, extension_key)? {
329                return Ok(Some(value));
330            }
331            if let Some(value) =
332                read_manifest_extension_json(&mut archive, "pack.manifest.json", extension_key)?
333            {
334                return Ok(Some(value));
335            }
336        }
337        Err(_) => {
338            if let Some(value) = read_manifest_extension_cbor_from_tar(path, extension_key)? {
339                return Ok(Some(value));
340            }
341        }
342    }
343    Ok(None)
344}
345
346/// Read a JSON asset from a `.gtpack` by pack-relative path.
347pub fn read_pack_json_asset(path: &Path, asset_path: &str) -> anyhow::Result<serde_json::Value> {
348    if !is_safe_pack_relative_path(asset_path) {
349        anyhow::bail!("unsafe pack asset path: {asset_path}");
350    }
351
352    let file = std::fs::File::open(path)?;
353    match zip::ZipArchive::new(file) {
354        Ok(mut archive) => {
355            let mut asset = archive.by_name(asset_path)?;
356            let mut contents = String::new();
357            std::io::Read::read_to_string(&mut asset, &mut contents)?;
358            Ok(serde_json::from_str(&contents)?)
359        }
360        Err(_) => read_pack_json_asset_from_tar(path, asset_path),
361    }
362}
363
364fn read_pack_json_asset_from_tar(
365    path: &Path,
366    asset_path: &str,
367) -> anyhow::Result<serde_json::Value> {
368    let file = std::fs::File::open(path)?;
369    let mut archive = tar::Archive::new(file);
370    for entry in archive.entries()? {
371        let mut entry = entry?;
372        if entry.path()?.as_ref() != Path::new(asset_path) {
373            continue;
374        }
375        let mut contents = String::new();
376        std::io::Read::read_to_string(&mut entry, &mut contents)?;
377        return Ok(serde_json::from_str(&contents)?);
378    }
379    anyhow::bail!("pack asset not found: {asset_path}")
380}
381
382/// Return whether a pack-relative asset exists in a `.gtpack`.
383pub fn pack_asset_exists(path: &Path, asset_path: &str) -> anyhow::Result<bool> {
384    if !is_safe_pack_relative_path(asset_path) {
385        anyhow::bail!("unsafe pack asset path: {asset_path}");
386    }
387
388    let file = std::fs::File::open(path)?;
389    match zip::ZipArchive::new(file) {
390        Ok(mut archive) => match archive.by_name(asset_path) {
391            Ok(_) => Ok(true),
392            Err(ZipError::FileNotFound) => Ok(false),
393            Err(err) => Err(err.into()),
394        },
395        Err(_) => pack_asset_exists_in_tar(path, asset_path),
396    }
397}
398
399fn pack_asset_exists_in_tar(path: &Path, asset_path: &str) -> anyhow::Result<bool> {
400    let file = std::fs::File::open(path)?;
401    let mut archive = tar::Archive::new(file);
402    for entry in archive.entries()? {
403        let entry = entry?;
404        if entry.path()?.as_ref() == Path::new(asset_path) {
405            return Ok(true);
406        }
407    }
408    Ok(false)
409}
410
411fn is_safe_pack_relative_path(path: &str) -> bool {
412    let path = Path::new(path);
413    !path.is_absolute()
414        && path
415            .components()
416            .all(|component| matches!(component, std::path::Component::Normal(_)))
417}
418
419fn write_json<T: Serialize>(path: &Path, value: &T) -> anyhow::Result<()> {
420    if let Some(parent) = path.parent() {
421        std::fs::create_dir_all(parent)?;
422    }
423    let payload = serde_json::to_string_pretty(value)?;
424    std::fs::write(path, payload)?;
425    Ok(())
426}
427
428// ── Manifest reading ────────────────────────────────────────────────────────
429
430fn read_pack_meta_from_manifest(path: &Path) -> anyhow::Result<Option<PackMeta>> {
431    let file = std::fs::File::open(path)?;
432    match zip::ZipArchive::new(file) {
433        Ok(mut archive) => {
434            if let Some(meta) = read_manifest_cbor(&mut archive)? {
435                return Ok(Some(meta));
436            }
437            if let Some(meta) = read_manifest_json(&mut archive, "pack.manifest.json")? {
438                return Ok(Some(meta));
439            }
440        }
441        Err(_) => {
442            if let Some(meta) = read_manifest_cbor_from_tar(path)? {
443                return Ok(Some(meta));
444            }
445        }
446    }
447    Ok(None)
448}
449
450fn read_pack_meta_cbor_only(path: &Path) -> anyhow::Result<Option<PackMeta>> {
451    let file = std::fs::File::open(path)?;
452    match zip::ZipArchive::new(file) {
453        Ok(mut archive) => read_manifest_cbor(&mut archive),
454        Err(_) => read_manifest_cbor_from_tar(path),
455    }
456}
457
458fn read_manifest_cbor(
459    archive: &mut zip::ZipArchive<std::fs::File>,
460) -> anyhow::Result<Option<PackMeta>> {
461    let mut file = match archive.by_name("manifest.cbor") {
462        Ok(file) => file,
463        Err(ZipError::FileNotFound) => return Ok(None),
464        Err(err) => return Err(err.into()),
465    };
466    let mut bytes = Vec::new();
467    std::io::Read::read_to_end(&mut file, &mut bytes)?;
468    let value: CborValue = serde_cbor::from_slice(&bytes)?;
469    extract_pack_meta_from_cbor(&value)
470}
471
472fn read_manifest_extension_cbor(
473    archive: &mut zip::ZipArchive<std::fs::File>,
474    extension_key: &str,
475) -> anyhow::Result<Option<serde_json::Value>> {
476    let mut file = match archive.by_name("manifest.cbor") {
477        Ok(file) => file,
478        Err(ZipError::FileNotFound) => return Ok(None),
479        Err(err) => return Err(err.into()),
480    };
481    let mut bytes = Vec::new();
482    std::io::Read::read_to_end(&mut file, &mut bytes)?;
483    let value: CborValue = serde_cbor::from_slice(&bytes)?;
484    let CborValue::Map(map) = &value else {
485        return Ok(None);
486    };
487    if let Some(value) = map_get(map, extension_key) {
488        return Ok(Some(cbor_to_json(value)));
489    }
490    let Some(CborValue::Map(extensions)) = map_get(map, "extensions") else {
491        return Ok(None);
492    };
493    Ok(map_get(extensions, extension_key).map(cbor_to_json))
494}
495
496fn read_manifest_json(
497    archive: &mut zip::ZipArchive<std::fs::File>,
498    name: &str,
499) -> anyhow::Result<Option<PackMeta>> {
500    let mut file = match archive.by_name(name) {
501        Ok(file) => file,
502        Err(ZipError::FileNotFound) => return Ok(None),
503        Err(err) => return Err(err.into()),
504    };
505    let mut contents = String::new();
506    std::io::Read::read_to_string(&mut file, &mut contents)?;
507    let parsed: serde_json::Value = serde_json::from_str(&contents)?;
508
509    let resolve_dn = |obj: &serde_json::Value| -> Option<String> {
510        obj.get("display_name")
511            .and_then(|v| v.as_str())
512            .or_else(|| obj.get("name").and_then(|v| v.as_str()))
513            .map(String::from)
514    };
515
516    let display_name = resolve_dn(&parsed);
517
518    if let Some(id) = parsed.get("pack_id").and_then(|v| v.as_str()) {
519        return Ok(Some(PackMeta {
520            pack_id: id.to_string(),
521            display_name,
522        }));
523    }
524    if let Some(meta) = parsed.get("meta")
525        && let Some(id) = meta.get("pack_id").and_then(|v| v.as_str())
526    {
527        let dn = resolve_dn(meta).or(display_name);
528        return Ok(Some(PackMeta {
529            pack_id: id.to_string(),
530            display_name: dn,
531        }));
532    }
533    Ok(None)
534}
535
536fn read_manifest_extension_json(
537    archive: &mut zip::ZipArchive<std::fs::File>,
538    name: &str,
539    extension_key: &str,
540) -> anyhow::Result<Option<serde_json::Value>> {
541    let mut file = match archive.by_name(name) {
542        Ok(file) => file,
543        Err(ZipError::FileNotFound) => return Ok(None),
544        Err(err) => return Err(err.into()),
545    };
546    let mut contents = String::new();
547    std::io::Read::read_to_string(&mut file, &mut contents)?;
548    let parsed: serde_json::Value = serde_json::from_str(&contents)?;
549    Ok(parsed.get(extension_key).cloned().or_else(|| {
550        parsed
551            .get("extensions")
552            .and_then(|extensions| extensions.get(extension_key))
553            .cloned()
554    }))
555}
556
557fn read_manifest_cbor_from_tar(path: &Path) -> anyhow::Result<Option<PackMeta>> {
558    let file = std::fs::File::open(path)?;
559    let mut archive = tar::Archive::new(file);
560    for entry in archive.entries()? {
561        let mut entry = entry?;
562        if entry.path()?.as_ref() != Path::new("manifest.cbor") {
563            continue;
564        }
565        let mut bytes = Vec::new();
566        std::io::Read::read_to_end(&mut entry, &mut bytes)?;
567        let value: CborValue = serde_cbor::from_slice(&bytes)?;
568        return extract_pack_meta_from_cbor(&value);
569    }
570    Ok(None)
571}
572
573fn read_manifest_extension_cbor_from_tar(
574    path: &Path,
575    extension_key: &str,
576) -> anyhow::Result<Option<serde_json::Value>> {
577    let file = std::fs::File::open(path)?;
578    let mut archive = tar::Archive::new(file);
579    for entry in archive.entries()? {
580        let mut entry = entry?;
581        if entry.path()?.as_ref() != Path::new("manifest.cbor") {
582            continue;
583        }
584        let mut bytes = Vec::new();
585        std::io::Read::read_to_end(&mut entry, &mut bytes)?;
586        let value: CborValue = serde_cbor::from_slice(&bytes)?;
587        let CborValue::Map(map) = &value else {
588            return Ok(None);
589        };
590        if let Some(value) = map_get(map, extension_key) {
591            return Ok(Some(cbor_to_json(value)));
592        }
593        let Some(CborValue::Map(extensions)) = map_get(map, "extensions") else {
594            return Ok(None);
595        };
596        return Ok(map_get(extensions, extension_key).map(cbor_to_json));
597    }
598    Ok(None)
599}
600
601fn extract_pack_meta_from_cbor(value: &CborValue) -> anyhow::Result<Option<PackMeta>> {
602    let CborValue::Map(map) = value else {
603        return Ok(None);
604    };
605    let symbols = match map_get(map, "symbols") {
606        Some(CborValue::Map(map)) => Some(map),
607        _ => None,
608    };
609
610    let resolve_display_name =
611        |source_map: &std::collections::BTreeMap<CborValue, CborValue>| -> Option<String> {
612            map_get(source_map, "display_name")
613                .and_then(|v| match v {
614                    CborValue::Text(text) => Some(text.clone()),
615                    _ => resolve_string_symbol(v, symbols, "display_names")
616                        .ok()
617                        .flatten(),
618                })
619                .or_else(|| {
620                    map_get(source_map, "name").and_then(|v| match v {
621                        CborValue::Text(text) => Some(text.clone()),
622                        _ => resolve_string_symbol(v, symbols, "names").ok().flatten(),
623                    })
624                })
625        };
626
627    if let Some(pack_id) = map_get(map, "pack_id")
628        && let Some(id) = resolve_string_symbol(pack_id, symbols, "pack_ids")?
629    {
630        return Ok(Some(PackMeta {
631            pack_id: id,
632            display_name: resolve_display_name(map),
633        }));
634    }
635
636    if let Some(CborValue::Map(meta)) = map_get(map, "meta")
637        && let Some(pack_id) = map_get(meta, "pack_id")
638        && let Some(id) = resolve_string_symbol(pack_id, symbols, "pack_ids")?
639    {
640        return Ok(Some(PackMeta {
641            pack_id: id,
642            display_name: resolve_display_name(meta).or_else(|| resolve_display_name(map)),
643        }));
644    }
645
646    Ok(None)
647}
648
649fn resolve_string_symbol(
650    value: &CborValue,
651    symbols: Option<&std::collections::BTreeMap<CborValue, CborValue>>,
652    symbol_key: &str,
653) -> anyhow::Result<Option<String>> {
654    match value {
655        CborValue::Text(text) => Ok(Some(text.clone())),
656        CborValue::Integer(idx) => {
657            let Some(symbols) = symbols else {
658                return Ok(Some(idx.to_string()));
659            };
660            let Some(CborValue::Array(values)) = map_get(symbols, symbol_key)
661                .or_else(|| map_get(symbols, symbol_key.strip_suffix('s').unwrap_or(symbol_key)))
662            else {
663                return Ok(Some(idx.to_string()));
664            };
665            let idx = usize::try_from(*idx).unwrap_or(usize::MAX);
666            match values.get(idx) {
667                Some(CborValue::Text(text)) => Ok(Some(text.clone())),
668                _ => Ok(Some(idx.to_string())),
669            }
670        }
671        _ => Ok(None),
672    }
673}
674
675fn map_get<'a>(
676    map: &'a std::collections::BTreeMap<CborValue, CborValue>,
677    key: &str,
678) -> Option<&'a CborValue> {
679    map.iter().find_map(|(k, v)| match k {
680        CborValue::Text(text) if text == key => Some(v),
681        _ => None,
682    })
683}
684
685fn cbor_to_json(value: &CborValue) -> serde_json::Value {
686    match value {
687        CborValue::Null => serde_json::Value::Null,
688        CborValue::Bool(v) => serde_json::Value::Bool(*v),
689        CborValue::Integer(v) => i64::try_from(*v)
690            .map(serde_json::Number::from)
691            .map(serde_json::Value::Number)
692            .unwrap_or_else(|_| serde_json::Value::String(v.to_string())),
693        CborValue::Float(v) => serde_json::Number::from_f64(*v)
694            .map(serde_json::Value::Number)
695            .unwrap_or(serde_json::Value::Null),
696        CborValue::Bytes(bytes) => {
697            serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(bytes))
698        }
699        CborValue::Text(text) => serde_json::Value::String(text.clone()),
700        CborValue::Array(values) => {
701            serde_json::Value::Array(values.iter().map(cbor_to_json).collect())
702        }
703        CborValue::Map(map) => {
704            let mut obj = serde_json::Map::new();
705            for (key, value) in map {
706                let key = match key {
707                    CborValue::Text(text) => text.clone(),
708                    CborValue::Integer(value) => value.to_string(),
709                    other => serde_json::to_string(&cbor_to_json(other)).unwrap_or_default(),
710                };
711                obj.insert(key, cbor_to_json(value));
712            }
713            serde_json::Value::Object(obj)
714        }
715        _ => serde_json::Value::Null,
716    }
717}
718
719fn missing_cbor_error(path: &Path) -> anyhow::Error {
720    anyhow::anyhow!(
721        "demo packs must be CBOR-only (.gtpack must contain manifest.cbor). \
722         Rebuild the pack with greentic-pack build (do not use --dev). Missing in {}",
723        path.display()
724    )
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730    use std::io::Write;
731    use zip::write::{FileOptions, ZipWriter};
732
733    fn write_test_pack(path: &Path, pack_id: &str, display_name: &str) -> anyhow::Result<()> {
734        let file = std::fs::File::create(path)?;
735        let mut writer = ZipWriter::new(file);
736        let options: FileOptions<'_, ()> =
737            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
738        writer.start_file("pack.manifest.json", options)?;
739        writer.write_all(
740            serde_json::json!({
741                "pack_id": pack_id,
742                "display_name": display_name,
743            })
744            .to_string()
745            .as_bytes(),
746        )?;
747        writer.finish()?;
748        Ok(())
749    }
750
751    fn write_test_pack_manifest(path: &Path, manifest: serde_json::Value) -> anyhow::Result<()> {
752        let file = std::fs::File::create(path)?;
753        let mut writer = ZipWriter::new(file);
754        let options: FileOptions<'_, ()> =
755            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
756        writer.start_file("pack.manifest.json", options)?;
757        writer.write_all(manifest.to_string().as_bytes())?;
758        writer.finish()?;
759        Ok(())
760    }
761
762    #[test]
763    fn discover_picks_up_extension_provider_in_wrapper_dir() -> anyhow::Result<()> {
764        // Layout the bundle wizard produces for extension providers (declared
765        // via `extension_provider_entries`): a directory under `providers/`
766        // ending in `.gtpack` that contains the actual `.gtpack` file inside.
767        // Before this fix the depth-1 directory was invisible to discovery,
768        // which silently dropped the provider's setup-answers page and
769        // prevented secrets like `jwt_signing_key` from ever reaching the
770        // dev secrets store at runtime.
771        let temp = tempfile::tempdir()?;
772        let root = temp.path();
773        let wrapper = root.join("providers").join("messaging-webchat-gui.gtpack");
774        std::fs::create_dir_all(&wrapper)?;
775        let inner = wrapper.join("messaging-webchat-gui.gtpack");
776        write_test_pack(&inner, "messaging-webchat-gui", "WebChat GUI")?;
777
778        let discovered = discover(root)?;
779        assert_eq!(discovered.providers.len(), 1);
780        let provider = &discovered.providers[0];
781        assert_eq!(provider.provider_id, "messaging-webchat-gui");
782        assert_eq!(provider.domain, "messaging");
783        assert_eq!(provider.kind, DetectedPackKind::Provider);
784        // pack_path must resolve to the inner zip so downstream consumers
785        // (setup_to_formspec::pack_to_form_spec, load_setup_spec) can open it
786        // with `File::open` + `ZipArchive::new`.
787        assert_eq!(provider.pack_path, inner);
788        assert!(
789            discovered
790                .find_setup_target("messaging-webchat-gui")
791                .is_some()
792        );
793        Ok(())
794    }
795
796    #[test]
797    fn discover_does_not_double_count_when_pack_lives_in_both_locations() -> anyhow::Result<()> {
798        // Defensive: if a pack happens to be installed both under the canonical
799        // `providers/<domain>/` dir AND as a wrapper dir at depth 1, we should
800        // pick exactly one (the DOMAIN_DIRS pass wins because it runs first).
801        let temp = tempfile::tempdir()?;
802        let root = temp.path();
803        std::fs::create_dir_all(root.join("providers/messaging"))?;
804        write_test_pack(
805            &root
806                .join("providers/messaging")
807                .join("messaging-telegram.gtpack"),
808            "messaging-telegram",
809            "Telegram",
810        )?;
811        let wrapper = root.join("providers").join("messaging-telegram.gtpack");
812        std::fs::create_dir_all(&wrapper)?;
813        write_test_pack(
814            &wrapper.join("messaging-telegram.gtpack"),
815            "messaging-telegram",
816            "Telegram",
817        )?;
818
819        let discovered = discover(root)?;
820        // Either path is acceptable for the de-duped entry; we just must not
821        // see the same provider twice.
822        let matching: Vec<_> = discovered
823            .providers
824            .iter()
825            .filter(|p| p.provider_id == "messaging-telegram")
826            .collect();
827        assert_eq!(
828            matching.len(),
829            1,
830            "expected exactly one entry, got {matching:?}"
831        );
832        Ok(())
833    }
834
835    #[test]
836    fn discover_includes_app_packs_in_setup_targets() -> anyhow::Result<()> {
837        let temp = tempfile::tempdir()?;
838        let root = temp.path();
839        std::fs::create_dir_all(root.join("providers/messaging"))?;
840        std::fs::create_dir_all(root.join("packs"))?;
841
842        write_test_pack(
843            &root
844                .join("providers")
845                .join("messaging")
846                .join("messaging-telegram.gtpack"),
847            "messaging-telegram",
848            "Telegram",
849        )?;
850        write_test_pack(
851            &root.join("packs").join("weather-app.gtpack"),
852            "weather-app",
853            "Weather App",
854        )?;
855
856        let discovered = discover(root)?;
857        assert_eq!(discovered.providers.len(), 1);
858        assert_eq!(discovered.app_packs.len(), 1);
859        assert_eq!(discovered.setup_targets().len(), 2);
860        assert_eq!(discovered.app_packs[0].provider_id, "weather-app");
861        assert_eq!(discovered.app_packs[0].domain, "app");
862        assert_eq!(discovered.app_packs[0].kind, DetectedPackKind::App);
863        Ok(())
864    }
865
866    #[test]
867    fn setup_targets_exclude_secrets_providers_and_deployer_packs() -> anyhow::Result<()> {
868        let temp = tempfile::tempdir()?;
869        let root = temp.path();
870        std::fs::create_dir_all(root.join("providers/messaging"))?;
871        std::fs::create_dir_all(root.join("providers/secrets"))?;
872        std::fs::create_dir_all(root.join("packs"))?;
873
874        write_test_pack(
875            &root
876                .join("providers")
877                .join("messaging")
878                .join("messaging-telegram.gtpack"),
879            "messaging-telegram",
880            "Telegram",
881        )?;
882        write_test_pack(
883            &root
884                .join("providers")
885                .join("secrets")
886                .join("secrets-vault.gtpack"),
887            "secrets-vault",
888            "Vault",
889        )?;
890        write_test_pack(&root.join("packs").join("aws.gtpack"), "aws", "AWS")?;
891
892        let discovered = discover(root)?;
893        let setup_ids: Vec<_> = discovered
894            .setup_targets()
895            .into_iter()
896            .map(|provider| provider.provider_id.as_str())
897            .collect();
898        assert_eq!(setup_ids, vec!["messaging-telegram"]);
899        assert!(discovered.find_setup_target("secrets-vault").is_none());
900        assert!(discovered.find_setup_target("aws").is_none());
901        Ok(())
902    }
903
904    #[test]
905    fn read_pack_extension_reads_json_manifest_extension() -> anyhow::Result<()> {
906        let temp = tempfile::tempdir()?;
907        let pack = temp.path().join("messaging-example.gtpack");
908        write_test_pack_manifest(
909            &pack,
910            serde_json::json!({
911                "pack_id": "messaging-example",
912                "extensions": {
913                    "messaging.oauth.v1": {
914                        "token_url": "https://example.com/token",
915                        "secret_keys": ["EXAMPLE_TOKEN"]
916                    }
917                }
918            }),
919        )?;
920        let extension = read_pack_extension(&pack, "messaging.oauth.v1")?.unwrap();
921        assert_eq!(extension["token_url"], "https://example.com/token");
922        Ok(())
923    }
924
925    #[test]
926    fn read_pack_extension_reads_cbor_manifest_extensions_map() -> anyhow::Result<()> {
927        let temp = tempfile::tempdir()?;
928        let pack = temp.path().join("messaging-example.gtpack");
929        let file = std::fs::File::create(&pack)?;
930        let mut writer = zip::ZipWriter::new(file);
931        let options: zip::write::FileOptions<'_, ()> =
932            zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored);
933        writer.start_file("manifest.cbor", options)?;
934        let manifest = serde_cbor::value::to_value(serde_json::json!({
935            "pack_id": "messaging-example",
936            "extensions": {
937                "messaging.oauth.v1": {
938                    "inline": {
939                        "token_url": "https://example.com/token"
940                    }
941                }
942            }
943        }))?;
944        writer.write_all(&serde_cbor::to_vec(&manifest)?)?;
945        writer.finish()?;
946
947        let extension = read_pack_extension(&pack, "messaging.oauth.v1")?.unwrap();
948        assert_eq!(
949            extension["inline"]["token_url"],
950            "https://example.com/token"
951        );
952        Ok(())
953    }
954}