1use std::path::{Path, PathBuf};
5
6use base64::Engine;
7use serde::Serialize;
8use serde_cbor::Value as CborValue;
9use zip::result::ZipError;
10
11#[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#[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#[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#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
42#[serde(rename_all = "snake_case")]
43pub enum DetectedPackKind {
44 Provider,
45 App,
46}
47
48#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
50#[serde(rename_all = "lowercase")]
51pub enum ProviderIdSource {
52 Manifest,
53 Filename,
54}
55
56struct PackMeta {
58 pack_id: String,
59 display_name: Option<String>,
60}
61
62#[derive(Clone, Debug)]
64pub struct DiscoveredPackMeta {
65 pub pack_id: String,
66 pub display_name: Option<String>,
67}
68
69#[derive(Default)]
71pub struct DiscoveryOptions {
72 pub cbor_only: bool,
74}
75
76const 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
85pub fn discover(root: &Path) -> anyhow::Result<DiscoveryResult> {
87 discover_with_options(root, DiscoveryOptions::default())
88}
89
90pub 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 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 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 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 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
244pub 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 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 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
283fn 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
310pub 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
320pub 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
346pub 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
382pub 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
428fn 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 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 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 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 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}