1use std::io::Read;
12use std::path::{Path, PathBuf};
13
14use anyhow::Context;
15use zip::ZipArchive;
16
17use crate::discovery;
18
19const EXT_CAPABILITIES_V1: &str = "greentic.ext.capabilities.v1";
20
21fn canonicalize_or_path(path: &Path) -> PathBuf {
22 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
23}
24
25pub struct UpgradeReport {
27 pub checked: usize,
28 pub upgraded: Vec<UpgradedPack>,
29 pub warnings: Vec<PackWarning>,
30}
31
32pub struct UpgradedPack {
33 pub provider_id: String,
34 pub source_path: PathBuf,
35}
36
37pub struct PackWarning {
38 pub provider_id: String,
39 pub message: String,
40}
41
42pub fn has_capabilities_extension(pack_path: &Path) -> bool {
44 read_has_capabilities(pack_path).unwrap_or(false)
45}
46
47fn read_has_capabilities(pack_path: &Path) -> anyhow::Result<bool> {
48 let file = std::fs::File::open(pack_path)?;
49 let mut archive = ZipArchive::new(file)?;
50 let mut entry = match archive.by_name("manifest.cbor") {
51 Ok(e) => e,
52 Err(_) => return Ok(false),
53 };
54 let mut bytes = Vec::new();
55 entry.read_to_end(&mut bytes)?;
56 Ok(bytes
60 .windows(EXT_CAPABILITIES_V1.len())
61 .any(|w| w == EXT_CAPABILITIES_V1.as_bytes()))
62}
63
64fn find_replacement_pack(pack_filename: &str, bundle_path: &Path, domain: &str) -> Option<PathBuf> {
70 let bundle_abs = canonicalize_or_path(bundle_path);
71 let parent = bundle_abs.parent()?;
72
73 if let Ok(entries) = std::fs::read_dir(parent) {
75 for entry in entries.flatten() {
76 let candidate_bundle = canonicalize_or_path(&entry.path());
77 if candidate_bundle == bundle_abs || !candidate_bundle.is_dir() {
78 continue;
79 }
80 let candidate = candidate_bundle
81 .join("providers")
82 .join(domain)
83 .join(pack_filename);
84 if candidate.is_file() && has_capabilities_extension(&candidate) {
85 return Some(candidate);
86 }
87 }
88 }
89
90 for ancestor in parent.ancestors().take(4) {
92 let candidate = ancestor
93 .join("greentic-messaging-providers")
94 .join("target")
95 .join("packs")
96 .join(pack_filename);
97 if candidate.is_file() && has_capabilities_extension(&candidate) {
98 return Some(candidate);
99 }
100 }
101
102 None
103}
104
105pub fn validate_and_upgrade_packs(bundle_path: &Path) -> anyhow::Result<UpgradeReport> {
107 let discovered = discovery::discover(bundle_path)
108 .context("failed to discover providers for capability validation")?;
109
110 let mut report = UpgradeReport {
111 checked: 0,
112 upgraded: Vec::new(),
113 warnings: Vec::new(),
114 };
115
116 for provider in &discovered.providers {
117 report.checked += 1;
118
119 if has_capabilities_extension(&provider.pack_path) {
120 continue;
121 }
122
123 let pack_filename = provider
124 .pack_path
125 .file_name()
126 .and_then(|n| n.to_str())
127 .unwrap_or("");
128
129 if pack_filename.is_empty() {
130 continue;
131 }
132
133 if let Some(replacement) =
135 find_replacement_pack(pack_filename, bundle_path, &provider.domain)
136 {
137 let backup = provider.pack_path.with_extension("gtpack.bak");
139 std::fs::copy(&provider.pack_path, &backup).with_context(|| {
140 format!(
141 "failed to backup {} before upgrade",
142 provider.pack_path.display()
143 )
144 })?;
145
146 std::fs::copy(&replacement, &provider.pack_path).with_context(|| {
148 format!(
149 "failed to copy replacement pack from {}",
150 replacement.display()
151 )
152 })?;
153
154 println!(
155 " [upgrade] {}: replaced with {} (capabilities extension added)",
156 provider.provider_id,
157 replacement.display()
158 );
159
160 report.upgraded.push(UpgradedPack {
161 provider_id: provider.provider_id.clone(),
162 source_path: replacement,
163 });
164 } else {
165 let msg = format!(
166 "pack missing greentic.ext.capabilities.v1 — operator will not detect this provider. \
167 Replace with a newer build of {}",
168 pack_filename,
169 );
170 println!(" [warn] {}: {}", provider.provider_id, msg);
171 report.warnings.push(PackWarning {
172 provider_id: provider.provider_id.clone(),
173 message: msg,
174 });
175 }
176 }
177
178 Ok(report)
179}
180
181pub struct DependencyReport {
187 pub satisfied: Vec<SatisfiedCapability>,
188 pub missing: Vec<MissingCapability>,
189}
190
191pub struct SatisfiedCapability {
192 pub capability: String,
193 pub required_by: String,
194 pub provided_by: String,
195}
196
197pub struct MissingCapability {
198 pub capability: String,
199 pub required_by: String,
200}
201
202pub fn validate_dependency_capabilities(bundle_path: &Path) -> anyhow::Result<DependencyReport> {
205 let discovered = discovery::discover(bundle_path)
206 .context("failed to discover providers for dependency validation")?;
207
208 let mut report = DependencyReport {
209 satisfied: Vec::new(),
210 missing: Vec::new(),
211 };
212
213 let mut capability_providers: std::collections::BTreeMap<String, String> =
215 std::collections::BTreeMap::new();
216 for provider in &discovered.providers {
217 if let Ok(caps) = read_pack_capabilities(&provider.pack_path) {
218 for cap_name in caps {
219 capability_providers
220 .entry(cap_name)
221 .or_insert_with(|| provider.provider_id.clone());
222 }
223 }
224 }
225
226 let mut pack_id_set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
228 for provider in &discovered.providers {
229 pack_id_set.insert(provider.provider_id.clone());
230 }
231
232 for provider in &discovered.providers {
233 let deps = match read_pack_dependencies(&provider.pack_path) {
234 Ok(d) => d,
235 Err(_) => continue,
236 };
237 for (dep_pack_id, required_caps) in deps {
238 if pack_id_set.contains(&dep_pack_id) {
240 continue;
241 }
242 for cap in &required_caps {
243 if let Some(provided_by) = capability_providers.get(cap) {
244 report.satisfied.push(SatisfiedCapability {
245 capability: cap.clone(),
246 required_by: provider.provider_id.clone(),
247 provided_by: provided_by.clone(),
248 });
249 } else {
250 report.missing.push(MissingCapability {
251 capability: cap.clone(),
252 required_by: provider.provider_id.clone(),
253 });
254 }
255 }
256 }
257 }
258
259 Ok(report)
260}
261
262fn read_pack_capabilities(pack_path: &Path) -> anyhow::Result<Vec<String>> {
264 let file = std::fs::File::open(pack_path)?;
265 let mut archive = ZipArchive::new(file)?;
266 let mut entry = archive.by_name("manifest.cbor")?;
267 let mut bytes = Vec::new();
268 entry.read_to_end(&mut bytes)?;
269 let cbor: serde_cbor::Value = serde_cbor::from_slice(&bytes)?;
270
271 let mut caps = Vec::new();
272 if let serde_cbor::Value::Map(ref map) = cbor
273 && let Some(serde_cbor::Value::Array(arr)) =
274 map.get(&serde_cbor::Value::Text("capabilities".to_string()))
275 {
276 for item in arr {
277 if let serde_cbor::Value::Map(cap_map) = item
278 && let Some(serde_cbor::Value::Text(name)) =
279 cap_map.get(&serde_cbor::Value::Text("name".to_string()))
280 {
281 caps.push(name.clone());
282 }
283 }
284 }
285 Ok(caps)
286}
287
288fn read_pack_dependencies(pack_path: &Path) -> anyhow::Result<Vec<(String, Vec<String>)>> {
291 let file = std::fs::File::open(pack_path)?;
292 let mut archive = ZipArchive::new(file)?;
293 let mut entry = archive.by_name("manifest.cbor")?;
294 let mut bytes = Vec::new();
295 entry.read_to_end(&mut bytes)?;
296 let cbor: serde_cbor::Value = serde_cbor::from_slice(&bytes)?;
297
298 let mut deps = Vec::new();
299 if let serde_cbor::Value::Map(ref map) = cbor
300 && let Some(serde_cbor::Value::Array(arr)) =
301 map.get(&serde_cbor::Value::Text("dependencies".to_string()))
302 {
303 for item in arr {
304 if let serde_cbor::Value::Map(dep_map) = item {
305 let pack_id = dep_map
306 .get(&serde_cbor::Value::Text("pack_id".to_string()))
307 .and_then(|v| {
308 if let serde_cbor::Value::Text(s) = v {
309 Some(s.clone())
310 } else {
311 None
312 }
313 })
314 .unwrap_or_default();
315 let req_caps: Vec<String> = dep_map
316 .get(&serde_cbor::Value::Text(
317 "required_capabilities".to_string(),
318 ))
319 .and_then(|v| {
320 if let serde_cbor::Value::Array(arr) = v {
321 Some(
322 arr.iter()
323 .filter_map(|item| {
324 if let serde_cbor::Value::Text(s) = item {
325 Some(s.clone())
326 } else {
327 None
328 }
329 })
330 .collect(),
331 )
332 } else {
333 None
334 }
335 })
336 .unwrap_or_default();
337 if !pack_id.is_empty() && !req_caps.is_empty() {
338 deps.push((pack_id, req_caps));
339 }
340 }
341 }
342 }
343 Ok(deps)
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use std::collections::BTreeMap;
350 use std::fs::File;
351 use std::io::Write;
352 use zip::write::{FileOptions, ZipWriter};
353
354 use serde_cbor::value::Value as CV;
355
356 fn write_test_gtpack(path: &Path, with_capabilities: bool) {
358 write_test_gtpack_manifest(path, "test-provider", with_capabilities, &[], &[], true);
359 }
360
361 fn write_test_gtpack_manifest(
362 path: &Path,
363 pack_id: &str,
364 with_extension: bool,
365 capabilities: &[&str],
366 dependencies: &[(&str, &[&str])],
367 include_manifest: bool,
368 ) {
369 let file = File::create(path).expect("create file");
370 let mut zip = ZipWriter::new(file);
371 if !include_manifest {
372 zip.start_file("README.txt", FileOptions::<()>::default())
373 .expect("start file");
374 zip.write_all(b"no manifest").expect("write placeholder");
375 zip.finish().expect("finish zip");
376 return;
377 }
378
379 let mut map = BTreeMap::new();
380 map.insert(
381 CV::Text("schema_version".into()),
382 CV::Text("pack-v1".into()),
383 );
384 map.insert(CV::Text("pack_id".into()), CV::Text(pack_id.into()));
385 map.insert(CV::Text("version".into()), CV::Text("0.1.0".into()));
386 map.insert(CV::Text("kind".into()), CV::Text("provider".into()));
387 map.insert(CV::Text("publisher".into()), CV::Text("test".into()));
388
389 if with_extension {
390 let mut ext_inner = BTreeMap::new();
391 ext_inner.insert(
392 CV::Text("kind".into()),
393 CV::Text(EXT_CAPABILITIES_V1.into()),
394 );
395 ext_inner.insert(CV::Text("version".into()), CV::Text("1.0.0".into()));
396
397 let mut exts = BTreeMap::new();
398 exts.insert(CV::Text(EXT_CAPABILITIES_V1.into()), CV::Map(ext_inner));
399 map.insert(CV::Text("extensions".into()), CV::Map(exts));
400 }
401
402 if !capabilities.is_empty() {
403 let caps = capabilities
404 .iter()
405 .map(|cap| {
406 CV::Map(BTreeMap::from([(
407 CV::Text("name".into()),
408 CV::Text((*cap).into()),
409 )]))
410 })
411 .collect();
412 map.insert(CV::Text("capabilities".into()), CV::Array(caps));
413 }
414
415 if !dependencies.is_empty() {
416 let deps = dependencies
417 .iter()
418 .map(|(pack, caps)| {
419 let req_caps = caps
420 .iter()
421 .map(|cap| CV::Text((*cap).into()))
422 .collect::<Vec<_>>();
423 CV::Map(BTreeMap::from([
424 (CV::Text("pack_id".into()), CV::Text((*pack).into())),
425 (
426 CV::Text("required_capabilities".into()),
427 CV::Array(req_caps),
428 ),
429 ]))
430 })
431 .collect();
432 map.insert(CV::Text("dependencies".into()), CV::Array(deps));
433 }
434
435 let manifest = CV::Map(map);
436 let bytes = serde_cbor::to_vec(&manifest).expect("encode cbor");
437 zip.start_file("manifest.cbor", FileOptions::<()>::default())
438 .expect("start file");
439 zip.write_all(&bytes).expect("write manifest");
440 zip.finish().expect("finish zip");
441 }
442
443 #[test]
444 fn has_capabilities_returns_true_when_present() {
445 let dir = tempfile::tempdir().unwrap();
446 let pack = dir.path().join("test.gtpack");
447 write_test_gtpack(&pack, true);
448 assert!(has_capabilities_extension(&pack));
449 }
450
451 #[test]
452 fn has_capabilities_returns_false_when_missing() {
453 let dir = tempfile::tempdir().unwrap();
454 let pack = dir.path().join("test.gtpack");
455 write_test_gtpack(&pack, false);
456 assert!(!has_capabilities_extension(&pack));
457 }
458
459 #[test]
460 fn has_capabilities_returns_false_for_nonexistent() {
461 assert!(!has_capabilities_extension(Path::new(
462 "/nonexistent.gtpack"
463 )));
464 }
465
466 #[test]
467 fn has_capabilities_returns_false_without_manifest_entry() {
468 let dir = tempfile::tempdir().unwrap();
469 let pack = dir.path().join("test.gtpack");
470 write_test_gtpack_manifest(&pack, "test-provider", false, &[], &[], false);
471 assert!(!has_capabilities_extension(&pack));
472 }
473
474 #[test]
475 fn find_replacement_from_sibling_bundle() {
476 let root = tempfile::tempdir().unwrap();
477
478 let bundle_a = root.path().join("bundle-a");
480 let providers_a = bundle_a.join("providers").join("messaging");
481 std::fs::create_dir_all(&providers_a).unwrap();
482 write_test_gtpack(&providers_a.join("messaging-test.gtpack"), false);
483
484 let bundle_b = root.path().join("bundle-b");
486 let providers_b = bundle_b.join("providers").join("messaging");
487 std::fs::create_dir_all(&providers_b).unwrap();
488 write_test_gtpack(&providers_b.join("messaging-test.gtpack"), true);
489
490 let result = find_replacement_pack("messaging-test.gtpack", &bundle_a, "messaging");
491 assert!(result.is_some());
492 assert!(
493 canonicalize_or_path(&result.unwrap()).starts_with(canonicalize_or_path(&bundle_b))
494 );
495 }
496
497 #[test]
498 fn find_replacement_returns_none_when_no_better_pack() {
499 let root = tempfile::tempdir().unwrap();
500 let bundle = root.path().join("bundle");
501 std::fs::create_dir_all(bundle.join("providers").join("messaging")).unwrap();
502 write_test_gtpack(
503 &bundle
504 .join("providers")
505 .join("messaging")
506 .join("test.gtpack"),
507 false,
508 );
509
510 let result = find_replacement_pack("test.gtpack", &bundle, "messaging");
511 assert!(result.is_none());
512 }
513
514 #[test]
515 fn find_replacement_from_ancestor_pack_output() {
516 let root = tempfile::tempdir().unwrap();
517 let nested = root.path().join("workspace").join("team").join("bundle");
518 std::fs::create_dir_all(nested.join("providers").join("messaging")).unwrap();
519 write_test_gtpack(
520 &nested
521 .join("providers")
522 .join("messaging")
523 .join("messaging-test.gtpack"),
524 false,
525 );
526
527 let pack_output = root
528 .path()
529 .join("workspace")
530 .join("greentic-messaging-providers")
531 .join("target")
532 .join("packs");
533 std::fs::create_dir_all(&pack_output).unwrap();
534 let replacement = pack_output.join("messaging-test.gtpack");
535 write_test_gtpack(&replacement, true);
536
537 let result = find_replacement_pack("messaging-test.gtpack", &nested, "messaging");
538 assert_eq!(
539 result.as_deref().map(canonicalize_or_path),
540 Some(canonicalize_or_path(&replacement))
541 );
542 }
543
544 #[test]
545 fn validate_and_upgrade_packs_warns_when_no_replacement_exists() {
546 let root = tempfile::tempdir().unwrap();
547 let bundle = root.path().join("bundle");
548 let providers = bundle.join("providers").join("messaging");
549 std::fs::create_dir_all(&providers).unwrap();
550 let pack = providers.join("messaging-test.gtpack");
551 write_test_gtpack_manifest(&pack, "messaging-test", false, &[], &[], true);
552
553 let report = validate_and_upgrade_packs(&bundle).unwrap();
554 assert_eq!(report.checked, 1);
555 assert!(report.upgraded.is_empty());
556 assert_eq!(report.warnings.len(), 1);
557 assert_eq!(report.warnings[0].provider_id, "messaging-test");
558 assert!(
559 report.warnings[0]
560 .message
561 .contains("Replace with a newer build")
562 );
563 assert!(!pack.with_extension("gtpack.bak").exists());
564 }
565
566 #[test]
567 fn validate_and_upgrade_packs_replaces_pack_and_writes_backup() {
568 let root = tempfile::tempdir().unwrap();
569 let bundle_a = root.path().join("bundle-a");
570 let providers_a = bundle_a.join("providers").join("messaging");
571 std::fs::create_dir_all(&providers_a).unwrap();
572 let original_pack = providers_a.join("messaging-test.gtpack");
573 write_test_gtpack_manifest(&original_pack, "messaging-test", false, &[], &[], true);
574
575 let bundle_b = root.path().join("bundle-b");
576 let providers_b = bundle_b.join("providers").join("messaging");
577 std::fs::create_dir_all(&providers_b).unwrap();
578 let replacement_pack = providers_b.join("messaging-test.gtpack");
579 write_test_gtpack_manifest(
580 &replacement_pack,
581 "messaging-test",
582 true,
583 &["cap.messaging"],
584 &[],
585 true,
586 );
587
588 let report = validate_and_upgrade_packs(&bundle_a).unwrap();
589 assert_eq!(report.checked, 1);
590 assert_eq!(report.upgraded.len(), 1);
591 assert!(report.warnings.is_empty());
592 assert_eq!(report.upgraded[0].provider_id, "messaging-test");
593 assert_eq!(
594 canonicalize_or_path(&report.upgraded[0].source_path),
595 canonicalize_or_path(&replacement_pack)
596 );
597 assert!(original_pack.with_extension("gtpack.bak").exists());
598 assert!(has_capabilities_extension(&original_pack));
599 }
600
601 #[test]
602 fn read_pack_capabilities_returns_declared_names() {
603 let dir = tempfile::tempdir().unwrap();
604 let pack = dir.path().join("provider.gtpack");
605 write_test_gtpack_manifest(
606 &pack,
607 "provider-a",
608 true,
609 &["cap.alpha", "cap.beta"],
610 &[],
611 true,
612 );
613
614 let caps = read_pack_capabilities(&pack).unwrap();
615 assert_eq!(caps, vec!["cap.alpha".to_string(), "cap.beta".to_string()]);
616 }
617
618 #[test]
619 fn read_pack_dependencies_ignores_incomplete_entries() {
620 let dir = tempfile::tempdir().unwrap();
621 let pack = dir.path().join("provider.gtpack");
622 write_test_gtpack_manifest(
623 &pack,
624 "provider-a",
625 false,
626 &[],
627 &[
628 ("pack-a", &["cap.alpha"]),
629 ("", &["cap.skip"]),
630 ("pack-b", &[]),
631 ],
632 true,
633 );
634
635 let deps = read_pack_dependencies(&pack).unwrap();
636 assert_eq!(
637 deps,
638 vec![("pack-a".to_string(), vec!["cap.alpha".to_string()])]
639 );
640 }
641
642 #[test]
643 fn validate_dependency_capabilities_tracks_satisfied_and_missing_caps() {
644 let root = tempfile::tempdir().unwrap();
645 let providers = root
646 .path()
647 .join("bundle")
648 .join("providers")
649 .join("messaging");
650 std::fs::create_dir_all(&providers).unwrap();
651
652 write_test_gtpack_manifest(
653 &providers.join("provider-a.gtpack"),
654 "provider-a",
655 true,
656 &["cap.shared"],
657 &[],
658 true,
659 );
660 write_test_gtpack_manifest(
661 &providers.join("provider-b.gtpack"),
662 "provider-b",
663 true,
664 &[],
665 &[("external-pack", &["cap.shared", "cap.missing"])],
666 true,
667 );
668 write_test_gtpack_manifest(
669 &providers.join("provider-c.gtpack"),
670 "provider-c",
671 true,
672 &[],
673 &[("provider-a", &["cap.shared"])],
674 true,
675 );
676
677 let report = validate_dependency_capabilities(&root.path().join("bundle")).unwrap();
678 assert_eq!(report.satisfied.len(), 1);
679 assert_eq!(report.satisfied[0].capability, "cap.shared");
680 assert_eq!(report.satisfied[0].required_by, "provider-b");
681 assert_eq!(report.satisfied[0].provided_by, "provider-a");
682 assert_eq!(report.missing.len(), 1);
683 assert_eq!(report.missing[0].capability, "cap.missing");
684 assert_eq!(report.missing[0].required_by, "provider-b");
685 }
686}