Skip to main content

greentic_setup/
capabilities.rs

1//! Capability validation and auto-upgrade for provider gtpacks.
2//!
3//! Provider gtpacks must contain a `greentic.ext.capabilities.v1` extension
4//! in their `manifest.cbor` for the operator to discover and mount them.
5//! Old gtpacks built before the capabilities extension was introduced will
6//! silently fail at runtime.
7//!
8//! This module provides validation during `gtc setup` and auto-upgrade from
9//! known source locations when a newer pack with capabilities is found.
10
11use 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
25/// Result of validating and upgrading packs in a bundle.
26pub 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
42/// Check whether a gtpack has the `greentic.ext.capabilities.v1` extension.
43pub 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    // Search for the capabilities extension key in the raw CBOR bytes.
57    // This avoids depending on the exact CBOR schema which may vary between
58    // greentic-types versions. The string is unique enough to be reliable.
59    Ok(bytes
60        .windows(EXT_CAPABILITIES_V1.len())
61        .any(|w| w == EXT_CAPABILITIES_V1.as_bytes()))
62}
63
64/// Search known source locations for a replacement gtpack that has capabilities.
65///
66/// Search order:
67/// 1. Sibling bundles in the same parent directory
68/// 2. `greentic-messaging-providers/target/packs/` in ancestor dirs
69fn 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    // 1. Sibling bundles: ../*/providers/{domain}/{filename}
74    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    // 2. greentic-messaging-providers build output in ancestor dirs
91    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
105/// Validate all provider gtpacks in a bundle and auto-upgrade those missing capabilities.
106pub 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        // Try to find a replacement
134        if let Some(replacement) =
135            find_replacement_pack(pack_filename, bundle_path, &provider.domain)
136        {
137            // Backup original
138            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            // Copy replacement
147            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
181// ---------------------------------------------------------------------------
182// Dependency capability validation
183// ---------------------------------------------------------------------------
184
185/// Report of dependency capability validation across all packs in the bundle.
186pub 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
202/// Validate that all pack dependencies have their required_capabilities
203/// satisfied by other packs in the bundle.
204pub 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    // Build capability index: capability_name → provider_id.
214    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    // Check each pack's dependencies.
227    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            // Skip if dependency pack_id is present directly.
239            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
262/// Read capability names from a gtpack manifest.
263fn 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
288/// Read dependencies from a gtpack manifest.
289/// Returns Vec of (pack_id, required_capabilities).
290fn 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    /// Write a minimal gtpack zip with a CBOR manifest.
357    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        // Bundle A: no capabilities
419        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        // Bundle B: has capabilities
425        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}