use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::Context;
use zip::ZipArchive;
use crate::discovery;
const EXT_CAPABILITIES_V1: &str = "greentic.ext.capabilities.v1";
fn canonicalize_or_path(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
pub struct UpgradeReport {
pub checked: usize,
pub upgraded: Vec<UpgradedPack>,
pub warnings: Vec<PackWarning>,
}
pub struct UpgradedPack {
pub provider_id: String,
pub source_path: PathBuf,
}
pub struct PackWarning {
pub provider_id: String,
pub message: String,
}
pub fn has_capabilities_extension(pack_path: &Path) -> bool {
read_has_capabilities(pack_path).unwrap_or(false)
}
fn read_has_capabilities(pack_path: &Path) -> anyhow::Result<bool> {
let file = std::fs::File::open(pack_path)?;
let mut archive = ZipArchive::new(file)?;
let mut entry = match archive.by_name("manifest.cbor") {
Ok(e) => e,
Err(_) => return Ok(false),
};
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)?;
Ok(bytes
.windows(EXT_CAPABILITIES_V1.len())
.any(|w| w == EXT_CAPABILITIES_V1.as_bytes()))
}
fn find_replacement_pack(pack_filename: &str, bundle_path: &Path, domain: &str) -> Option<PathBuf> {
let bundle_abs = canonicalize_or_path(bundle_path);
let parent = bundle_abs.parent()?;
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let candidate_bundle = canonicalize_or_path(&entry.path());
if candidate_bundle == bundle_abs || !candidate_bundle.is_dir() {
continue;
}
let candidate = candidate_bundle
.join("providers")
.join(domain)
.join(pack_filename);
if candidate.is_file() && has_capabilities_extension(&candidate) {
return Some(candidate);
}
}
}
for ancestor in parent.ancestors().take(4) {
let candidate = ancestor
.join("greentic-messaging-providers")
.join("target")
.join("packs")
.join(pack_filename);
if candidate.is_file() && has_capabilities_extension(&candidate) {
return Some(candidate);
}
}
None
}
pub fn validate_and_upgrade_packs(bundle_path: &Path) -> anyhow::Result<UpgradeReport> {
let discovered = discovery::discover(bundle_path)
.context("failed to discover providers for capability validation")?;
let mut report = UpgradeReport {
checked: 0,
upgraded: Vec::new(),
warnings: Vec::new(),
};
for provider in &discovered.providers {
report.checked += 1;
if has_capabilities_extension(&provider.pack_path) {
continue;
}
let pack_filename = provider
.pack_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if pack_filename.is_empty() {
continue;
}
if let Some(replacement) =
find_replacement_pack(pack_filename, bundle_path, &provider.domain)
{
let backup = provider.pack_path.with_extension("gtpack.bak");
std::fs::copy(&provider.pack_path, &backup).with_context(|| {
format!(
"failed to backup {} before upgrade",
provider.pack_path.display()
)
})?;
std::fs::copy(&replacement, &provider.pack_path).with_context(|| {
format!(
"failed to copy replacement pack from {}",
replacement.display()
)
})?;
println!(
" [upgrade] {}: replaced with {} (capabilities extension added)",
provider.provider_id,
replacement.display()
);
report.upgraded.push(UpgradedPack {
provider_id: provider.provider_id.clone(),
source_path: replacement,
});
} else {
let msg = format!(
"pack missing greentic.ext.capabilities.v1 — operator will not detect this provider. \
Replace with a newer build of {}",
pack_filename,
);
println!(" [warn] {}: {}", provider.provider_id, msg);
report.warnings.push(PackWarning {
provider_id: provider.provider_id.clone(),
message: msg,
});
}
}
Ok(report)
}
pub struct DependencyReport {
pub satisfied: Vec<SatisfiedCapability>,
pub missing: Vec<MissingCapability>,
}
pub struct SatisfiedCapability {
pub capability: String,
pub required_by: String,
pub provided_by: String,
}
pub struct MissingCapability {
pub capability: String,
pub required_by: String,
}
pub fn validate_dependency_capabilities(bundle_path: &Path) -> anyhow::Result<DependencyReport> {
let discovered = discovery::discover(bundle_path)
.context("failed to discover providers for dependency validation")?;
let mut report = DependencyReport {
satisfied: Vec::new(),
missing: Vec::new(),
};
let mut capability_providers: std::collections::BTreeMap<String, String> =
std::collections::BTreeMap::new();
for provider in &discovered.providers {
if let Ok(caps) = read_pack_capabilities(&provider.pack_path) {
for cap_name in caps {
capability_providers
.entry(cap_name)
.or_insert_with(|| provider.provider_id.clone());
}
}
}
let mut pack_id_set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for provider in &discovered.providers {
pack_id_set.insert(provider.provider_id.clone());
}
for provider in &discovered.providers {
let deps = match read_pack_dependencies(&provider.pack_path) {
Ok(d) => d,
Err(_) => continue,
};
for (dep_pack_id, required_caps) in deps {
if pack_id_set.contains(&dep_pack_id) {
continue;
}
for cap in &required_caps {
if let Some(provided_by) = capability_providers.get(cap) {
report.satisfied.push(SatisfiedCapability {
capability: cap.clone(),
required_by: provider.provider_id.clone(),
provided_by: provided_by.clone(),
});
} else {
report.missing.push(MissingCapability {
capability: cap.clone(),
required_by: provider.provider_id.clone(),
});
}
}
}
}
Ok(report)
}
fn read_pack_capabilities(pack_path: &Path) -> anyhow::Result<Vec<String>> {
let file = std::fs::File::open(pack_path)?;
let mut archive = ZipArchive::new(file)?;
let mut entry = archive.by_name("manifest.cbor")?;
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)?;
let cbor: serde_cbor::Value = serde_cbor::from_slice(&bytes)?;
let mut caps = Vec::new();
if let serde_cbor::Value::Map(ref map) = cbor
&& let Some(serde_cbor::Value::Array(arr)) =
map.get(&serde_cbor::Value::Text("capabilities".to_string()))
{
for item in arr {
if let serde_cbor::Value::Map(cap_map) = item
&& let Some(serde_cbor::Value::Text(name)) =
cap_map.get(&serde_cbor::Value::Text("name".to_string()))
{
caps.push(name.clone());
}
}
}
Ok(caps)
}
fn read_pack_dependencies(pack_path: &Path) -> anyhow::Result<Vec<(String, Vec<String>)>> {
let file = std::fs::File::open(pack_path)?;
let mut archive = ZipArchive::new(file)?;
let mut entry = archive.by_name("manifest.cbor")?;
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)?;
let cbor: serde_cbor::Value = serde_cbor::from_slice(&bytes)?;
let mut deps = Vec::new();
if let serde_cbor::Value::Map(ref map) = cbor
&& let Some(serde_cbor::Value::Array(arr)) =
map.get(&serde_cbor::Value::Text("dependencies".to_string()))
{
for item in arr {
if let serde_cbor::Value::Map(dep_map) = item {
let pack_id = dep_map
.get(&serde_cbor::Value::Text("pack_id".to_string()))
.and_then(|v| {
if let serde_cbor::Value::Text(s) = v {
Some(s.clone())
} else {
None
}
})
.unwrap_or_default();
let req_caps: Vec<String> = dep_map
.get(&serde_cbor::Value::Text(
"required_capabilities".to_string(),
))
.and_then(|v| {
if let serde_cbor::Value::Array(arr) = v {
Some(
arr.iter()
.filter_map(|item| {
if let serde_cbor::Value::Text(s) = item {
Some(s.clone())
} else {
None
}
})
.collect(),
)
} else {
None
}
})
.unwrap_or_default();
if !pack_id.is_empty() && !req_caps.is_empty() {
deps.push((pack_id, req_caps));
}
}
}
}
Ok(deps)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::fs::File;
use std::io::Write;
use zip::write::{FileOptions, ZipWriter};
use serde_cbor::value::Value as CV;
fn write_test_gtpack(path: &Path, with_capabilities: bool) {
write_test_gtpack_manifest(path, "test-provider", with_capabilities, &[], &[], true);
}
fn write_test_gtpack_manifest(
path: &Path,
pack_id: &str,
with_extension: bool,
capabilities: &[&str],
dependencies: &[(&str, &[&str])],
include_manifest: bool,
) {
let file = File::create(path).expect("create file");
let mut zip = ZipWriter::new(file);
if !include_manifest {
zip.start_file("README.txt", FileOptions::<()>::default())
.expect("start file");
zip.write_all(b"no manifest").expect("write placeholder");
zip.finish().expect("finish zip");
return;
}
let mut map = BTreeMap::new();
map.insert(
CV::Text("schema_version".into()),
CV::Text("pack-v1".into()),
);
map.insert(CV::Text("pack_id".into()), CV::Text(pack_id.into()));
map.insert(CV::Text("version".into()), CV::Text("0.1.0".into()));
map.insert(CV::Text("kind".into()), CV::Text("provider".into()));
map.insert(CV::Text("publisher".into()), CV::Text("test".into()));
if with_extension {
let mut ext_inner = BTreeMap::new();
ext_inner.insert(
CV::Text("kind".into()),
CV::Text(EXT_CAPABILITIES_V1.into()),
);
ext_inner.insert(CV::Text("version".into()), CV::Text("1.0.0".into()));
let mut exts = BTreeMap::new();
exts.insert(CV::Text(EXT_CAPABILITIES_V1.into()), CV::Map(ext_inner));
map.insert(CV::Text("extensions".into()), CV::Map(exts));
}
if !capabilities.is_empty() {
let caps = capabilities
.iter()
.map(|cap| {
CV::Map(BTreeMap::from([(
CV::Text("name".into()),
CV::Text((*cap).into()),
)]))
})
.collect();
map.insert(CV::Text("capabilities".into()), CV::Array(caps));
}
if !dependencies.is_empty() {
let deps = dependencies
.iter()
.map(|(pack, caps)| {
let req_caps = caps
.iter()
.map(|cap| CV::Text((*cap).into()))
.collect::<Vec<_>>();
CV::Map(BTreeMap::from([
(CV::Text("pack_id".into()), CV::Text((*pack).into())),
(
CV::Text("required_capabilities".into()),
CV::Array(req_caps),
),
]))
})
.collect();
map.insert(CV::Text("dependencies".into()), CV::Array(deps));
}
let manifest = CV::Map(map);
let bytes = serde_cbor::to_vec(&manifest).expect("encode cbor");
zip.start_file("manifest.cbor", FileOptions::<()>::default())
.expect("start file");
zip.write_all(&bytes).expect("write manifest");
zip.finish().expect("finish zip");
}
#[test]
fn has_capabilities_returns_true_when_present() {
let dir = tempfile::tempdir().unwrap();
let pack = dir.path().join("test.gtpack");
write_test_gtpack(&pack, true);
assert!(has_capabilities_extension(&pack));
}
#[test]
fn has_capabilities_returns_false_when_missing() {
let dir = tempfile::tempdir().unwrap();
let pack = dir.path().join("test.gtpack");
write_test_gtpack(&pack, false);
assert!(!has_capabilities_extension(&pack));
}
#[test]
fn has_capabilities_returns_false_for_nonexistent() {
assert!(!has_capabilities_extension(Path::new(
"/nonexistent.gtpack"
)));
}
#[test]
fn has_capabilities_returns_false_without_manifest_entry() {
let dir = tempfile::tempdir().unwrap();
let pack = dir.path().join("test.gtpack");
write_test_gtpack_manifest(&pack, "test-provider", false, &[], &[], false);
assert!(!has_capabilities_extension(&pack));
}
#[test]
fn find_replacement_from_sibling_bundle() {
let root = tempfile::tempdir().unwrap();
let bundle_a = root.path().join("bundle-a");
let providers_a = bundle_a.join("providers").join("messaging");
std::fs::create_dir_all(&providers_a).unwrap();
write_test_gtpack(&providers_a.join("messaging-test.gtpack"), false);
let bundle_b = root.path().join("bundle-b");
let providers_b = bundle_b.join("providers").join("messaging");
std::fs::create_dir_all(&providers_b).unwrap();
write_test_gtpack(&providers_b.join("messaging-test.gtpack"), true);
let result = find_replacement_pack("messaging-test.gtpack", &bundle_a, "messaging");
assert!(result.is_some());
assert!(
canonicalize_or_path(&result.unwrap()).starts_with(canonicalize_or_path(&bundle_b))
);
}
#[test]
fn find_replacement_returns_none_when_no_better_pack() {
let root = tempfile::tempdir().unwrap();
let bundle = root.path().join("bundle");
std::fs::create_dir_all(bundle.join("providers").join("messaging")).unwrap();
write_test_gtpack(
&bundle
.join("providers")
.join("messaging")
.join("test.gtpack"),
false,
);
let result = find_replacement_pack("test.gtpack", &bundle, "messaging");
assert!(result.is_none());
}
#[test]
fn find_replacement_from_ancestor_pack_output() {
let root = tempfile::tempdir().unwrap();
let nested = root.path().join("workspace").join("team").join("bundle");
std::fs::create_dir_all(nested.join("providers").join("messaging")).unwrap();
write_test_gtpack(
&nested
.join("providers")
.join("messaging")
.join("messaging-test.gtpack"),
false,
);
let pack_output = root
.path()
.join("workspace")
.join("greentic-messaging-providers")
.join("target")
.join("packs");
std::fs::create_dir_all(&pack_output).unwrap();
let replacement = pack_output.join("messaging-test.gtpack");
write_test_gtpack(&replacement, true);
let result = find_replacement_pack("messaging-test.gtpack", &nested, "messaging");
assert_eq!(
result.as_deref().map(canonicalize_or_path),
Some(canonicalize_or_path(&replacement))
);
}
#[test]
fn validate_and_upgrade_packs_warns_when_no_replacement_exists() {
let root = tempfile::tempdir().unwrap();
let bundle = root.path().join("bundle");
let providers = bundle.join("providers").join("messaging");
std::fs::create_dir_all(&providers).unwrap();
let pack = providers.join("messaging-test.gtpack");
write_test_gtpack_manifest(&pack, "messaging-test", false, &[], &[], true);
let report = validate_and_upgrade_packs(&bundle).unwrap();
assert_eq!(report.checked, 1);
assert!(report.upgraded.is_empty());
assert_eq!(report.warnings.len(), 1);
assert_eq!(report.warnings[0].provider_id, "messaging-test");
assert!(
report.warnings[0]
.message
.contains("Replace with a newer build")
);
assert!(!pack.with_extension("gtpack.bak").exists());
}
#[test]
fn validate_and_upgrade_packs_replaces_pack_and_writes_backup() {
let root = tempfile::tempdir().unwrap();
let bundle_a = root.path().join("bundle-a");
let providers_a = bundle_a.join("providers").join("messaging");
std::fs::create_dir_all(&providers_a).unwrap();
let original_pack = providers_a.join("messaging-test.gtpack");
write_test_gtpack_manifest(&original_pack, "messaging-test", false, &[], &[], true);
let bundle_b = root.path().join("bundle-b");
let providers_b = bundle_b.join("providers").join("messaging");
std::fs::create_dir_all(&providers_b).unwrap();
let replacement_pack = providers_b.join("messaging-test.gtpack");
write_test_gtpack_manifest(
&replacement_pack,
"messaging-test",
true,
&["cap.messaging"],
&[],
true,
);
let report = validate_and_upgrade_packs(&bundle_a).unwrap();
assert_eq!(report.checked, 1);
assert_eq!(report.upgraded.len(), 1);
assert!(report.warnings.is_empty());
assert_eq!(report.upgraded[0].provider_id, "messaging-test");
assert_eq!(
canonicalize_or_path(&report.upgraded[0].source_path),
canonicalize_or_path(&replacement_pack)
);
assert!(original_pack.with_extension("gtpack.bak").exists());
assert!(has_capabilities_extension(&original_pack));
}
#[test]
fn read_pack_capabilities_returns_declared_names() {
let dir = tempfile::tempdir().unwrap();
let pack = dir.path().join("provider.gtpack");
write_test_gtpack_manifest(
&pack,
"provider-a",
true,
&["cap.alpha", "cap.beta"],
&[],
true,
);
let caps = read_pack_capabilities(&pack).unwrap();
assert_eq!(caps, vec!["cap.alpha".to_string(), "cap.beta".to_string()]);
}
#[test]
fn read_pack_dependencies_ignores_incomplete_entries() {
let dir = tempfile::tempdir().unwrap();
let pack = dir.path().join("provider.gtpack");
write_test_gtpack_manifest(
&pack,
"provider-a",
false,
&[],
&[
("pack-a", &["cap.alpha"]),
("", &["cap.skip"]),
("pack-b", &[]),
],
true,
);
let deps = read_pack_dependencies(&pack).unwrap();
assert_eq!(
deps,
vec![("pack-a".to_string(), vec!["cap.alpha".to_string()])]
);
}
#[test]
fn validate_dependency_capabilities_tracks_satisfied_and_missing_caps() {
let root = tempfile::tempdir().unwrap();
let providers = root
.path()
.join("bundle")
.join("providers")
.join("messaging");
std::fs::create_dir_all(&providers).unwrap();
write_test_gtpack_manifest(
&providers.join("provider-a.gtpack"),
"provider-a",
true,
&["cap.shared"],
&[],
true,
);
write_test_gtpack_manifest(
&providers.join("provider-b.gtpack"),
"provider-b",
true,
&[],
&[("external-pack", &["cap.shared", "cap.missing"])],
true,
);
write_test_gtpack_manifest(
&providers.join("provider-c.gtpack"),
"provider-c",
true,
&[],
&[("provider-a", &["cap.shared"])],
true,
);
let report = validate_dependency_capabilities(&root.path().join("bundle")).unwrap();
assert_eq!(report.satisfied.len(), 1);
assert_eq!(report.satisfied[0].capability, "cap.shared");
assert_eq!(report.satisfied[0].required_by, "provider-b");
assert_eq!(report.satisfied[0].provided_by, "provider-a");
assert_eq!(report.missing.len(), 1);
assert_eq!(report.missing[0].capability, "cap.missing");
assert_eq!(report.missing[0].required_by, "provider-b");
}
}