use super::*;
use std::fs::File;
fn make_project(root: &Path) -> PathBuf {
let desc = br#"{
"apiVersion": "greentic.ai/v2",
"kind": "DesignExtension",
"compat": {"min_designer_version": ">=1.0.0", "min_runner_version": "^0.12.0", "contract_version": "1.2.4-research"},
"metadata": {"id": "com.example.demo", "name": "demo", "version": "0.1.0", "summary": "x", "author": {"name": "a"}, "license": "Apache-2.0"},
"engine": {"greenticDesigner": "^0.1.0", "extRuntime": "^0.1.0"},
"capabilities": {"offered": [], "required": []},
"runtime": {"components": {"stub": {"oci_ref": "oci://ghcr.io/example/stub:latest", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "world": "greentic:component/stub@0.1.0"}}, "permissions": {"network": [], "secrets": [], "callExtensionKinds": []}},
"contributions": {}
}"#;
std::fs::write(root.join("describe.json"), desc).unwrap();
let wasm_dir = root.join("target/wasm32-wasip2/debug");
std::fs::create_dir_all(&wasm_dir).unwrap();
let wasm = wasm_dir.join("demo.wasm");
std::fs::write(&wasm, b"\0asm\x01\x00\x00\x00").unwrap();
wasm
}
#[test]
fn build_pack_produces_zip_with_describe_and_wasm() {
let tmp = tempfile::tempdir().unwrap();
let wasm = make_project(tmp.path());
let out = tmp.path().join("dist/demo-0.1.0.gtxpack");
let info = build_pack(tmp.path(), &wasm, &out).unwrap();
assert_eq!(info.ext_name, "demo");
assert_eq!(info.ext_version, "0.1.0");
assert_eq!(info.ext_kind, "DesignExtension");
assert!(info.size > 0);
let file = File::open(&out).unwrap();
let mut zip = zip::ZipArchive::new(file).unwrap();
let names: Vec<_> = (0..zip.len())
.map(|i| zip.by_index(i).unwrap().name().to_string())
.collect();
assert!(names.iter().any(|n| n == "describe.json"));
assert!(names.iter().any(|n| n == "extension.wasm"));
}
#[test]
fn build_pack_with_key_produces_verifiable_signed_pack() {
let tmp = tempfile::tempdir().unwrap();
let wasm = make_project(tmp.path());
let out = tmp.path().join("dist/demo-0.1.0.gtxpack");
let sk = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
let info = build_pack_with_key(tmp.path(), &wasm, &out, Some(&sk)).unwrap();
let zip_bytes = std::fs::read(&out).unwrap();
let manifest_bytes = read_manifest_from_zip(&zip_bytes).unwrap();
let describe: DescribeJson = serde_json::from_slice(&info.describe_bytes).unwrap();
greentic_extension_sdk_contract::verify_archive_against_manifest(&zip_bytes).unwrap();
greentic_extension_sdk_contract::verify_manifest_binding(&describe, &manifest_bytes).unwrap();
greentic_extension_sdk_contract::verify_describe_with_key(&describe, &sk.verifying_key())
.unwrap();
}
#[test]
fn verify_produced_pack_accepts_good_and_rejects_tampered() {
let tmp = tempfile::tempdir().unwrap();
let wasm = make_project(tmp.path());
let out = tmp.path().join("dist/demo-0.1.0.gtxpack");
let sk = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
let info = build_pack_with_key(tmp.path(), &wasm, &out, Some(&sk)).unwrap();
let zip_bytes = std::fs::read(&out).unwrap();
let describe: DescribeJson = serde_json::from_slice(&info.describe_bytes).unwrap();
verify_produced_pack(&zip_bytes, &describe, Some(&sk)).unwrap();
let other = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]);
assert!(verify_produced_pack(&zip_bytes, &describe, Some(&other)).is_err());
}
#[test]
fn build_pack_unsigned_is_manifest_bound_but_unsigned() {
let tmp = tempfile::tempdir().unwrap();
let wasm = make_project(tmp.path());
let out = tmp.path().join("dist/demo-0.1.0.gtxpack");
let info = build_pack(tmp.path(), &wasm, &out).unwrap();
let describe: DescribeJson = serde_json::from_slice(&info.describe_bytes).unwrap();
assert!(
describe.manifest_sha256.is_some(),
"manifest must be bound even without a signing key"
);
assert!(describe.signature.is_none(), "no key → no signature");
let zip_bytes = std::fs::read(&out).unwrap();
let manifest_bytes = read_manifest_from_zip(&zip_bytes).unwrap();
greentic_extension_sdk_contract::verify_manifest_binding(&describe, &manifest_bytes).unwrap();
}
#[test]
fn build_pack_is_deterministic_across_runs() {
let tmp = tempfile::tempdir().unwrap();
let wasm = make_project(tmp.path());
let out1 = tmp.path().join("a.gtxpack");
let out2 = tmp.path().join("b.gtxpack");
let a = build_pack(tmp.path(), &wasm, &out1).unwrap();
let b = build_pack(tmp.path(), &wasm, &out2).unwrap();
assert_eq!(a.sha256, b.sha256);
}
fn describe_with_gtpack_file(file: &str) -> serde_json::Value {
serde_json::json!({
"runtime": { "components": {
"comp": { "gtpack": {
"file": file,
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
}}
}}
})
}
#[test]
fn collect_runtime_files_rejects_parent_dir_traversal() {
let tmp = tempfile::tempdir().unwrap();
let describe = describe_with_gtpack_file("../escape.gtpack");
let mut entries = Vec::new();
let err = collect_runtime_component_files(
&describe,
tmp.path(),
&tmp.path().join("out.gtxpack"),
&mut entries,
)
.unwrap_err();
assert!(
err.to_string().contains("project-relative"),
"expected a path-traversal rejection, got: {err}"
);
}
#[test]
fn collect_runtime_files_rejects_absolute_path() {
let tmp = tempfile::tempdir().unwrap();
let describe = describe_with_gtpack_file("/etc/passwd");
let mut entries = Vec::new();
let err = collect_runtime_component_files(
&describe,
tmp.path(),
&tmp.path().join("out.gtxpack"),
&mut entries,
)
.unwrap_err();
assert!(
err.to_string().contains("project-relative"),
"expected a path-traversal rejection, got: {err}"
);
}
#[test]
fn collect_runtime_files_accepts_legit_relative_path() {
let tmp = tempfile::tempdir().unwrap();
let bytes = b"runtime-pack-bytes";
std::fs::create_dir_all(tmp.path().join("runtime")).unwrap();
std::fs::write(tmp.path().join("runtime/p.gtpack"), bytes).unwrap();
let describe = serde_json::json!({
"runtime": { "components": {
"comp": { "gtpack": { "file": "runtime/p.gtpack", "sha256": sha256_hex(bytes) }}
}}
});
let mut entries = Vec::new();
collect_runtime_component_files(
&describe,
tmp.path(),
&tmp.path().join("out.gtxpack"),
&mut entries,
)
.unwrap();
assert_eq!(entries.len(), 1);
}
#[test]
fn build_pack_includes_assets_when_present() {
let tmp = tempfile::tempdir().unwrap();
let wasm = make_project(tmp.path());
std::fs::create_dir_all(tmp.path().join("i18n")).unwrap();
std::fs::write(tmp.path().join("i18n/en.json"), br#"{"hello":"world"}"#).unwrap();
let out = tmp.path().join("demo.gtxpack");
build_pack(tmp.path(), &wasm, &out).unwrap();
let file = File::open(&out).unwrap();
let zip = zip::ZipArchive::new(file).unwrap();
let names: Vec<_> = zip.file_names().map(str::to_string).collect();
assert!(names.iter().any(|n| n == "i18n/en.json"));
}
#[test]
fn build_pack_includes_icon_assets_dir() {
let tmp = tempfile::tempdir().unwrap();
let wasm = make_project(tmp.path());
std::fs::create_dir_all(tmp.path().join("assets")).unwrap();
std::fs::write(
tmp.path().join("assets/icon.svg"),
br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"/>"#,
)
.unwrap();
let out = tmp.path().join("demo.gtxpack");
build_pack(tmp.path(), &wasm, &out).unwrap();
let file = File::open(&out).unwrap();
let zip = zip::ZipArchive::new(file).unwrap();
let names: Vec<_> = zip.file_names().map(str::to_string).collect();
assert!(
names.iter().any(|n| n == "assets/icon.svg"),
"assets/icon.svg missing from gtxpack; entries: {names:?}"
);
}
#[test]
fn build_pack_errors_if_describe_missing() {
let tmp = tempfile::tempdir().unwrap();
let wasm_dir = tmp.path().join("target/wasm32-wasip2/debug");
std::fs::create_dir_all(&wasm_dir).unwrap();
std::fs::write(wasm_dir.join("x.wasm"), b"\0asm").unwrap();
let out = tmp.path().join("out.gtxpack");
let err = build_pack(tmp.path(), &wasm_dir.join("x.wasm"), &out).unwrap_err();
assert!(err.to_string().contains("describe.json"));
}
fn write_provider_describe(root: &Path, gtpack_field: &serde_json::Value) {
const ZERO_SHA: &str = "0000000000000000000000000000000000000000000000000000000000000000";
let component = if gtpack_field.is_null() {
serde_json::json!({
"oci_ref": "oci://ghcr.io/example/provider:latest",
"sha256": ZERO_SHA,
"world": "greentic:component/provider@0.1.0"
})
} else {
let mut gtpack = gtpack_field.clone();
let obj = gtpack.as_object_mut().expect("gtpack block is an object");
obj.entry("pack_id")
.or_insert_with(|| serde_json::json!("com.example.provider.runtime"));
obj.entry("component_version")
.or_insert_with(|| serde_json::json!("0.1.0"));
serde_json::json!({
"gtpack": gtpack,
"sha256": ZERO_SHA,
"world": "greentic:component/provider@0.1.0"
})
};
let desc = serde_json::json!({
"apiVersion": "greentic.ai/v2",
"kind": "ProviderExtension",
"compat": {
"min_designer_version": ">=1.0.0",
"min_runner_version": "^0.12.0",
"contract_version": "1.2.4-research"
},
"metadata": {
"id": "com.example.provider",
"name": "provider",
"version": "0.1.0",
"summary": "test provider",
"author": {"name": "tester"},
"license": "Apache-2.0"
},
"engine": {"greenticDesigner": "^0.1.0", "extRuntime": "^0.1.0"},
"capabilities": {"offered": [], "required": []},
"runtime": {
"components": { "provider": component },
"permissions": {"network": [], "secrets": [], "callExtensionKinds": []}
},
"contributions": {}
});
std::fs::write(root.join("describe.json"), desc.to_string()).unwrap();
}
fn make_provider_project(root: &Path) -> (PathBuf, Vec<u8>, String) {
let gtpack_bytes = b"fake-gtpack-content-for-testing".to_vec();
let sha = sha256_hex(>pack_bytes);
std::fs::create_dir_all(root.join("runtime")).unwrap();
std::fs::write(root.join("runtime/provider.gtpack"), >pack_bytes).unwrap();
write_provider_describe(
root,
&serde_json::json!({
"file": "runtime/provider.gtpack",
"sha256": sha
}),
);
let wasm_dir = root.join("target/wasm32-wasip2/debug");
std::fs::create_dir_all(&wasm_dir).unwrap();
let wasm = wasm_dir.join("provider.wasm");
std::fs::write(&wasm, b"\0asm\x01\x00\x00\x00").unwrap();
(wasm, gtpack_bytes, sha)
}
#[test]
fn provider_pack_includes_runtime_gtpack() {
let tmp = tempfile::tempdir().unwrap();
let (wasm, gtpack_bytes, _sha) = make_provider_project(tmp.path());
let out = tmp.path().join("dist/provider-0.1.0.gtxpack");
let info = build_pack(tmp.path(), &wasm, &out).unwrap();
assert_eq!(info.ext_name, "provider");
assert_eq!(info.ext_kind, "ProviderExtension");
let file = File::open(&out).unwrap();
let mut zip = zip::ZipArchive::new(file).unwrap();
let names: Vec<_> = (0..zip.len())
.map(|i| zip.by_index(i).unwrap().name().to_string())
.collect();
assert!(
names.iter().any(|n| n == "describe.json"),
"missing describe.json"
);
assert!(
names.iter().any(|n| n == "extension.wasm"),
"missing extension.wasm"
);
assert!(
names.iter().any(|n| n == "runtime/provider.gtpack"),
"missing runtime/provider.gtpack; entries: {names:?}"
);
let mut archive = zip::ZipArchive::new(File::open(&out).unwrap()).unwrap();
let mut entry = archive.by_name("runtime/provider.gtpack").unwrap();
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut entry, &mut buf).unwrap();
assert_eq!(buf, gtpack_bytes);
}
#[test]
fn provider_pack_fails_when_runtime_file_missing() {
let tmp = tempfile::tempdir().unwrap();
let gtpack_bytes = b"fake-gtpack-content-for-testing".to_vec();
let sha = sha256_hex(>pack_bytes);
write_provider_describe(
tmp.path(),
&serde_json::json!({
"file": "runtime/provider.gtpack",
"sha256": sha
}),
);
let wasm_dir = tmp.path().join("target/wasm32-wasip2/debug");
std::fs::create_dir_all(&wasm_dir).unwrap();
let wasm = wasm_dir.join("provider.wasm");
std::fs::write(&wasm, b"\0asm\x01\x00\x00\x00").unwrap();
let out = tmp.path().join("out.gtxpack");
let err = build_pack(tmp.path(), &wasm, &out).unwrap_err();
assert!(
err.to_string().contains("not found"),
"expected 'not found' in error; got: {err}"
);
}
#[test]
fn provider_pack_fails_when_sha256_mismatch() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("runtime")).unwrap();
std::fs::write(tmp.path().join("runtime/provider.gtpack"), b"real-content").unwrap();
write_provider_describe(
tmp.path(),
&serde_json::json!({
"file": "runtime/provider.gtpack",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
}),
);
let wasm_dir = tmp.path().join("target/wasm32-wasip2/debug");
std::fs::create_dir_all(&wasm_dir).unwrap();
let wasm = wasm_dir.join("provider.wasm");
std::fs::write(&wasm, b"\0asm\x01\x00\x00\x00").unwrap();
let out = tmp.path().join("out.gtxpack");
let err = build_pack(tmp.path(), &wasm, &out).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("mismatch") || msg.contains("sha256"),
"expected 'mismatch' or 'sha256' in error; got: {msg}"
);
}
#[test]
fn design_pack_unchanged_without_gtpack() {
let tmp = tempfile::tempdir().unwrap();
let wasm = make_project(tmp.path());
let out = tmp.path().join("dist/demo-0.1.0.gtxpack");
let info = build_pack(tmp.path(), &wasm, &out).unwrap();
assert_eq!(info.ext_kind, "DesignExtension");
let file = File::open(&out).unwrap();
let mut zip = zip::ZipArchive::new(file).unwrap();
let names: Vec<_> = (0..zip.len())
.map(|i| zip.by_index(i).unwrap().name().to_string())
.collect();
assert_eq!(
names.len(),
3,
"expected describe + wasm + manifest; got: {names:?}"
);
assert!(names.iter().any(|n| n == "describe.json"));
assert!(names.iter().any(|n| n == "extension.wasm"));
assert!(names.iter().any(|n| n == "manifest.json"));
}
fn read_published_secrets(out: &Path) -> Vec<String> {
let mut archive = zip::ZipArchive::new(File::open(out).unwrap()).unwrap();
let mut entry = archive.by_name("describe.json").unwrap();
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut entry, &mut buf).unwrap();
let describe: serde_json::Value = serde_json::from_slice(&buf).unwrap();
describe["runtime"]["permissions"]["secrets"]
.as_array()
.expect("permissions.secrets must be an array in the published describe")
.iter()
.map(|v| v.as_str().expect("secret key is a string").to_string())
.collect()
}
fn read_published_required_secrets(out: &Path) -> Vec<String> {
let mut archive = zip::ZipArchive::new(File::open(out).unwrap()).unwrap();
let mut entry = archive.by_name("describe.json").unwrap();
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut entry, &mut buf).unwrap();
let describe: serde_json::Value = serde_json::from_slice(&buf).unwrap();
match describe["requiredSecrets"].as_array() {
None => Vec::new(),
Some(arr) => arr
.iter()
.map(|v| {
v["key"]
.as_str()
.expect("requiredSecrets[].key is a string")
.to_string()
})
.collect(),
}
}
fn make_gtpack_with_secret_keys(keys: &[&str]) -> Vec<u8> {
use greentic_types::{
PackId, PackKind, PackManifest, PackSignatures, SecretKey, SecretRequirement,
};
let secret_requirements = keys
.iter()
.map(|k| {
let mut req = SecretRequirement::default();
req.key = SecretKey::new(*k).expect("valid secret key");
req
})
.collect();
let manifest = PackManifest {
schema_version: "1".to_string(),
pack_id: PackId::new("com.example.provider.runtime").expect("valid pack id"),
name: None,
version: semver::Version::new(0, 1, 0),
kind: PackKind::Provider,
publisher: "tester".to_string(),
components: Vec::new(),
flows: Vec::new(),
dependencies: Vec::new(),
capabilities: Vec::new(),
secret_requirements,
signatures: PackSignatures::default(),
bootstrap: None,
extensions: None,
agents: std::collections::BTreeMap::new(),
};
let cbor = greentic_types::encode_pack_manifest(&manifest).expect("encode manifest");
let mut buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let opts: zip::write::FileOptions<'_, ()> =
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored);
zip::write::ZipWriter::start_file(&mut zip, "manifest.cbor", opts).unwrap();
std::io::Write::write_all(&mut zip, &cbor).unwrap();
zip.finish().unwrap();
}
buf
}
fn make_provider_project_with_secret_pack(
root: &Path,
keys: &[&str],
author_secrets: &[&str],
) -> PathBuf {
let gtpack_bytes = make_gtpack_with_secret_keys(keys);
let sha = sha256_hex(>pack_bytes);
std::fs::create_dir_all(root.join("runtime")).unwrap();
std::fs::write(root.join("runtime/provider.gtpack"), >pack_bytes).unwrap();
write_provider_describe(
root,
&serde_json::json!({ "file": "runtime/provider.gtpack", "sha256": sha }),
);
if !author_secrets.is_empty() {
let path = root.join("describe.json");
let mut describe: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
let reqs: Vec<serde_json::Value> = author_secrets
.iter()
.map(|k| serde_json::json!({"key": k}))
.collect();
describe["requiredSecrets"] = serde_json::json!(reqs);
std::fs::write(&path, describe.to_string()).unwrap();
}
let wasm_dir = root.join("target/wasm32-wasip2/debug");
std::fs::create_dir_all(&wasm_dir).unwrap();
let wasm = wasm_dir.join("provider.wasm");
std::fs::write(&wasm, b"\0asm\x01\x00\x00\x00").unwrap();
wasm
}
#[test]
fn provider_pack_enriches_secrets_from_nested_gtpack() {
let tmp = tempfile::tempdir().unwrap();
let wasm = make_provider_project_with_secret_pack(tmp.path(), &["B_TOKEN", "A_TOKEN"], &[]);
let out = tmp.path().join("dist/provider-0.1.0.gtxpack");
build_pack(tmp.path(), &wasm, &out).unwrap();
assert_eq!(
read_published_required_secrets(&out),
vec!["A_TOKEN".to_string(), "B_TOKEN".to_string()],
"requiredSecrets must be auto-enriched from the nested .gtpack manifest"
);
assert!(
read_published_secrets(&out).is_empty(),
"permissions.secrets must not be populated by the enrichment step"
);
}
#[test]
fn provider_pack_merges_author_and_nested_secrets() {
let tmp = tempfile::tempdir().unwrap();
let wasm = make_provider_project_with_secret_pack(
tmp.path(),
&["B_TOKEN", "A_TOKEN"],
&["C_TOKEN", "A_TOKEN"],
);
let out = tmp.path().join("dist/provider-0.1.0.gtxpack");
build_pack(tmp.path(), &wasm, &out).unwrap();
assert_eq!(
read_published_required_secrets(&out),
vec![
"A_TOKEN".to_string(),
"B_TOKEN".to_string(),
"C_TOKEN".to_string()
],
"requiredSecrets must be the deduped, sorted union of author + nested keys"
);
assert!(
read_published_secrets(&out).is_empty(),
"permissions.secrets must not be populated by the enrichment step"
);
}
#[test]
fn provider_pack_no_secrets_leaves_permissions_empty() {
let tmp = tempfile::tempdir().unwrap();
let wasm = make_provider_project_with_secret_pack(tmp.path(), &[], &[]);
let out = tmp.path().join("dist/provider-0.1.0.gtxpack");
build_pack(tmp.path(), &wasm, &out).unwrap();
assert!(
read_published_required_secrets(&out).is_empty(),
"empty nested secret_requirements must leave requiredSecrets empty"
);
assert!(
read_published_secrets(&out).is_empty(),
"empty nested secret_requirements must leave permissions.secrets empty"
);
}
#[test]
fn provider_pack_handles_absent_gtpack_field() {
let tmp = tempfile::tempdir().unwrap();
write_provider_describe(tmp.path(), &serde_json::Value::Null);
let wasm_dir = tmp.path().join("target/wasm32-wasip2/debug");
std::fs::create_dir_all(&wasm_dir).unwrap();
let wasm = wasm_dir.join("provider.wasm");
std::fs::write(&wasm, b"\0asm\x01\x00\x00\x00").unwrap();
let out = tmp.path().join("out.gtxpack");
let info = build_pack(tmp.path(), &wasm, &out).unwrap();
let file = File::open(&out).unwrap();
let mut zip = zip::ZipArchive::new(file).unwrap();
let names: Vec<_> = (0..zip.len())
.map(|i| zip.by_index(i).unwrap().name().to_string())
.collect();
assert_eq!(
names.len(),
3,
"absent gtpack should produce describe + wasm + manifest; got: {names:?}"
);
let _ = info;
}