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
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 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 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 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}