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#[cfg(test)]
182mod tests {
183    use super::*;
184    use std::collections::BTreeMap;
185    use std::fs::File;
186    use std::io::Write;
187    use zip::write::{FileOptions, ZipWriter};
188
189    use serde_cbor::value::Value as CV;
190
191    /// Write a minimal gtpack zip with a CBOR manifest.
192    fn write_test_gtpack(path: &Path, with_capabilities: bool) {
193        let mut map = BTreeMap::new();
194        map.insert(
195            CV::Text("schema_version".into()),
196            CV::Text("pack-v1".into()),
197        );
198        map.insert(CV::Text("pack_id".into()), CV::Text("test-provider".into()));
199        map.insert(CV::Text("version".into()), CV::Text("0.1.0".into()));
200        map.insert(CV::Text("kind".into()), CV::Text("provider".into()));
201        map.insert(CV::Text("publisher".into()), CV::Text("test".into()));
202
203        if with_capabilities {
204            let mut ext_inner = BTreeMap::new();
205            ext_inner.insert(
206                CV::Text("kind".into()),
207                CV::Text(EXT_CAPABILITIES_V1.into()),
208            );
209            ext_inner.insert(CV::Text("version".into()), CV::Text("1.0.0".into()));
210
211            let mut exts = BTreeMap::new();
212            exts.insert(CV::Text(EXT_CAPABILITIES_V1.into()), CV::Map(ext_inner));
213            map.insert(CV::Text("extensions".into()), CV::Map(exts));
214        }
215
216        let manifest = CV::Map(map);
217        let bytes = serde_cbor::to_vec(&manifest).expect("encode cbor");
218        let file = File::create(path).expect("create file");
219        let mut zip = ZipWriter::new(file);
220        zip.start_file("manifest.cbor", FileOptions::<()>::default())
221            .expect("start file");
222        zip.write_all(&bytes).expect("write manifest");
223        zip.finish().expect("finish zip");
224    }
225
226    #[test]
227    fn has_capabilities_returns_true_when_present() {
228        let dir = tempfile::tempdir().unwrap();
229        let pack = dir.path().join("test.gtpack");
230        write_test_gtpack(&pack, true);
231        assert!(has_capabilities_extension(&pack));
232    }
233
234    #[test]
235    fn has_capabilities_returns_false_when_missing() {
236        let dir = tempfile::tempdir().unwrap();
237        let pack = dir.path().join("test.gtpack");
238        write_test_gtpack(&pack, false);
239        assert!(!has_capabilities_extension(&pack));
240    }
241
242    #[test]
243    fn has_capabilities_returns_false_for_nonexistent() {
244        assert!(!has_capabilities_extension(Path::new(
245            "/nonexistent.gtpack"
246        )));
247    }
248
249    #[test]
250    fn find_replacement_from_sibling_bundle() {
251        let root = tempfile::tempdir().unwrap();
252
253        // Bundle A: no capabilities
254        let bundle_a = root.path().join("bundle-a");
255        let providers_a = bundle_a.join("providers").join("messaging");
256        std::fs::create_dir_all(&providers_a).unwrap();
257        write_test_gtpack(&providers_a.join("messaging-test.gtpack"), false);
258
259        // Bundle B: has capabilities
260        let bundle_b = root.path().join("bundle-b");
261        let providers_b = bundle_b.join("providers").join("messaging");
262        std::fs::create_dir_all(&providers_b).unwrap();
263        write_test_gtpack(&providers_b.join("messaging-test.gtpack"), true);
264
265        let result = find_replacement_pack("messaging-test.gtpack", &bundle_a, "messaging");
266        assert!(result.is_some());
267        assert!(
268            canonicalize_or_path(&result.unwrap()).starts_with(canonicalize_or_path(&bundle_b))
269        );
270    }
271
272    #[test]
273    fn find_replacement_returns_none_when_no_better_pack() {
274        let root = tempfile::tempdir().unwrap();
275        let bundle = root.path().join("bundle");
276        std::fs::create_dir_all(bundle.join("providers").join("messaging")).unwrap();
277        write_test_gtpack(
278            &bundle
279                .join("providers")
280                .join("messaging")
281                .join("test.gtpack"),
282            false,
283        );
284
285        let result = find_replacement_pack("test.gtpack", &bundle, "messaging");
286        assert!(result.is_none());
287    }
288}