greentic_setup/
capabilities.rs1use 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 let mut map = BTreeMap::new();
359 map.insert(
360 CV::Text("schema_version".into()),
361 CV::Text("pack-v1".into()),
362 );
363 map.insert(CV::Text("pack_id".into()), CV::Text("test-provider".into()));
364 map.insert(CV::Text("version".into()), CV::Text("0.1.0".into()));
365 map.insert(CV::Text("kind".into()), CV::Text("provider".into()));
366 map.insert(CV::Text("publisher".into()), CV::Text("test".into()));
367
368 if with_capabilities {
369 let mut ext_inner = BTreeMap::new();
370 ext_inner.insert(
371 CV::Text("kind".into()),
372 CV::Text(EXT_CAPABILITIES_V1.into()),
373 );
374 ext_inner.insert(CV::Text("version".into()), CV::Text("1.0.0".into()));
375
376 let mut exts = BTreeMap::new();
377 exts.insert(CV::Text(EXT_CAPABILITIES_V1.into()), CV::Map(ext_inner));
378 map.insert(CV::Text("extensions".into()), CV::Map(exts));
379 }
380
381 let manifest = CV::Map(map);
382 let bytes = serde_cbor::to_vec(&manifest).expect("encode cbor");
383 let file = File::create(path).expect("create file");
384 let mut zip = ZipWriter::new(file);
385 zip.start_file("manifest.cbor", FileOptions::<()>::default())
386 .expect("start file");
387 zip.write_all(&bytes).expect("write manifest");
388 zip.finish().expect("finish zip");
389 }
390
391 #[test]
392 fn has_capabilities_returns_true_when_present() {
393 let dir = tempfile::tempdir().unwrap();
394 let pack = dir.path().join("test.gtpack");
395 write_test_gtpack(&pack, true);
396 assert!(has_capabilities_extension(&pack));
397 }
398
399 #[test]
400 fn has_capabilities_returns_false_when_missing() {
401 let dir = tempfile::tempdir().unwrap();
402 let pack = dir.path().join("test.gtpack");
403 write_test_gtpack(&pack, false);
404 assert!(!has_capabilities_extension(&pack));
405 }
406
407 #[test]
408 fn has_capabilities_returns_false_for_nonexistent() {
409 assert!(!has_capabilities_extension(Path::new(
410 "/nonexistent.gtpack"
411 )));
412 }
413
414 #[test]
415 fn find_replacement_from_sibling_bundle() {
416 let root = tempfile::tempdir().unwrap();
417
418 let bundle_a = root.path().join("bundle-a");
420 let providers_a = bundle_a.join("providers").join("messaging");
421 std::fs::create_dir_all(&providers_a).unwrap();
422 write_test_gtpack(&providers_a.join("messaging-test.gtpack"), false);
423
424 let bundle_b = root.path().join("bundle-b");
426 let providers_b = bundle_b.join("providers").join("messaging");
427 std::fs::create_dir_all(&providers_b).unwrap();
428 write_test_gtpack(&providers_b.join("messaging-test.gtpack"), true);
429
430 let result = find_replacement_pack("messaging-test.gtpack", &bundle_a, "messaging");
431 assert!(result.is_some());
432 assert!(
433 canonicalize_or_path(&result.unwrap()).starts_with(canonicalize_or_path(&bundle_b))
434 );
435 }
436
437 #[test]
438 fn find_replacement_returns_none_when_no_better_pack() {
439 let root = tempfile::tempdir().unwrap();
440 let bundle = root.path().join("bundle");
441 std::fs::create_dir_all(bundle.join("providers").join("messaging")).unwrap();
442 write_test_gtpack(
443 &bundle
444 .join("providers")
445 .join("messaging")
446 .join("test.gtpack"),
447 false,
448 );
449
450 let result = find_replacement_pack("test.gtpack", &bundle, "messaging");
451 assert!(result.is_none());
452 }
453}