use std::path::{Path, PathBuf};
use greentic_extension_sdk_contract::pack_writer::{
PackEntry, build_gtxpack_with_manifest, sha256_hex,
};
use greentic_extension_sdk_contract::{DescribeJson, bind_manifest, sign_describe};
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub struct PackInfo {
pub pack_path: PathBuf,
pub pack_name: String,
pub size: u64,
pub sha256: String,
pub ext_name: String,
pub ext_version: String,
#[allow(dead_code)] pub ext_kind: String,
pub describe_bytes: Vec<u8>,
}
fn collect_runtime_component_files(
describe: &serde_json::Value,
project_dir: &Path,
output_pack: &Path,
entries: &mut Vec<PackEntry>,
) -> anyhow::Result<()> {
let output_pack_name = output_pack
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
let Some(components) = describe["runtime"]
.get("components")
.and_then(|v| v.as_object())
else {
return Ok(());
};
let mut seen_files: std::collections::HashSet<String> = std::collections::HashSet::new();
for (comp_key, comp) in components {
let Some(gtpack) = comp.get("gtpack").filter(|v| !v.is_null()) else {
continue;
};
let file_rel = gtpack["file"].as_str().ok_or_else(|| {
anyhow::anyhow!(
"describe.runtime.components.{comp_key}.gtpack.file missing or not a string"
)
})?;
if file_rel == "extension.wasm" || file_rel == output_pack_name {
continue;
}
if !seen_files.insert(file_rel.to_string()) {
continue;
}
let expected_sha = gtpack["sha256"].as_str().ok_or_else(|| {
anyhow::anyhow!(
"describe.runtime.components.{comp_key}.gtpack.sha256 missing or not a string"
)
})?;
let candidate = Path::new(file_rel);
if candidate.is_absolute()
|| candidate
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
anyhow::bail!(
"describe.runtime.components.{comp_key}.gtpack.file = {file_rel:?} must be a \
project-relative path with no `..` or absolute components"
);
}
let abs = project_dir.join(file_rel);
if !abs.exists() {
anyhow::bail!(
"describe.runtime.components.{comp_key}.gtpack.file = {file_rel:?} but file not found at {}.\n\
Multi-component extensions must stage their runtime .gtpack into the project before publish.\n\
For pilot/dev, ship a placeholder file at the declared path with sha256 matching describe.json.",
abs.display()
);
}
let bytes = std::fs::read(&abs)
.map_err(|e| anyhow::anyhow!("read runtime gtpack at {}: {e}", abs.display()))?;
let actual_sha = sha256_hex(&bytes);
if actual_sha != expected_sha {
anyhow::bail!(
"describe.runtime.components.{comp_key}.gtpack.sha256 mismatch for {file_rel}:\n\
declared: {expected_sha}\n\
actual: {actual_sha}\n\
Either rebuild the runtime + update describe.json, or update describe.json to match the staged file."
);
}
entries.push(PackEntry::file(file_rel.to_string(), bytes));
}
Ok(())
}
fn read_gtpack_secret_requirements(
gtpack_path: &Path,
) -> anyhow::Result<Vec<greentic_types::secrets::SecretRequirement>> {
use std::io::Read as _;
let bytes = std::fs::read(gtpack_path)
.map_err(|e| anyhow::anyhow!("read {}: {e}", gtpack_path.display()))?;
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes))
.map_err(|e| anyhow::anyhow!("open {} as zip: {e}", gtpack_path.display()))?;
let mut entry = archive
.by_name("manifest.cbor")
.map_err(|e| anyhow::anyhow!("no manifest.cbor in {}: {e}", gtpack_path.display()))?;
let mut cbor = Vec::new();
entry
.read_to_end(&mut cbor)
.map_err(|e| anyhow::anyhow!("read manifest.cbor in {}: {e}", gtpack_path.display()))?;
let manifest = greentic_types::decode_pack_manifest(&cbor)
.map_err(|e| anyhow::anyhow!("decode manifest.cbor in {}: {e}", gtpack_path.display()))?;
Ok(manifest.secret_requirements)
}
fn enrich_describe_secrets(describe: &mut DescribeJson, project_dir: &Path) {
let mut seen: std::collections::BTreeMap<String, greentic_types::secrets::SecretRequirement> =
describe
.required_secrets
.drain(..)
.map(|r| (r.key.as_str().to_string(), r))
.collect();
for (comp_key, component) in &describe.runtime.components {
let Some(gtpack) = component.gtpack.as_ref() else {
continue;
};
let abs = project_dir.join(>pack.file);
match read_gtpack_secret_requirements(&abs) {
Ok(reqs) => {
for req in reqs {
seen.entry(req.key.as_str().to_string()).or_insert(req);
}
}
Err(err) => {
tracing::warn!(
component = %comp_key,
file = %gtpack.file,
error = %err,
"skipping secret enrichment for runtime component: \
not a readable .gtpack with a decodable manifest.cbor"
);
}
}
}
describe.required_secrets = seen.into_values().collect();
}
pub fn build_pack(
project_dir: &Path,
wasm_path: &Path,
output_pack: &Path,
) -> anyhow::Result<PackInfo> {
build_pack_with_key(project_dir, wasm_path, output_pack, None)
}
fn read_manifest_from_zip(zip_bytes: &[u8]) -> anyhow::Result<Vec<u8>> {
use std::io::Read as _;
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes))?;
let mut entry = archive
.by_name("manifest.json")
.map_err(|e| anyhow::anyhow!("packer produced no manifest.json: {e}"))?;
let mut buf = Vec::new();
entry.read_to_end(&mut buf)?;
Ok(buf)
}
pub fn build_pack_with_key(
project_dir: &Path,
wasm_path: &Path,
output_pack: &Path,
signing_key: Option<&ed25519_dalek::SigningKey>,
) -> anyhow::Result<PackInfo> {
let describe_path = project_dir.join("describe.json");
let describe_bytes =
std::fs::read(&describe_path).map_err(|e| anyhow::anyhow!("read describe.json: {e}"))?;
let describe: serde_json::Value = serde_json::from_slice(&describe_bytes)
.map_err(|e| anyhow::anyhow!("parse describe.json: {e}"))?;
let ext_name = describe["metadata"]["name"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("describe.metadata.name missing"))?
.to_string();
let ext_version = describe["metadata"]["version"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("describe.metadata.version missing"))?
.to_string();
let ext_kind = describe["kind"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("describe.kind missing"))?
.to_string();
let mut entries = vec![
PackEntry::file("describe.json", describe_bytes),
PackEntry::file("extension.wasm", std::fs::read(wasm_path)?),
];
collect_runtime_component_files(&describe, project_dir, output_pack, &mut entries)?;
for asset_dir in ["i18n", "schemas", "prompts", "assets"] {
let src = project_dir.join(asset_dir);
if !src.is_dir() {
continue;
}
let mut paths: Vec<PathBuf> = WalkDir::new(&src)
.into_iter()
.flatten()
.filter(|e| e.file_type().is_file())
.map(|e| e.path().to_path_buf())
.collect();
paths.sort();
for abs in paths {
let rel = abs
.strip_prefix(project_dir)
.expect("asset under project")
.to_string_lossy()
.replace('\\', "/");
entries.push(PackEntry::file(rel, std::fs::read(&abs)?));
}
}
if let Some(parent) = output_pack.parent() {
std::fs::create_dir_all(parent)?;
}
let zip1 = build_gtxpack_with_manifest(entries.clone())
.map_err(|e| anyhow::anyhow!("build_gtxpack_with_manifest: {e}"))?;
let manifest_bytes = read_manifest_from_zip(&zip1)?;
let mut describe_typed: DescribeJson = serde_json::from_value(describe.clone())
.map_err(|e| anyhow::anyhow!("parse describe.json as typed: {e}"))?;
enrich_describe_secrets(&mut describe_typed, project_dir);
bind_manifest(&mut describe_typed, &manifest_bytes);
if let Some(key) = signing_key {
sign_describe(&mut describe_typed, key).map_err(|e| anyhow::anyhow!("sign: {e}"))?;
}
let final_describe_bytes = serde_json::to_vec_pretty(&describe_typed)?;
if let Some(entry) = entries.iter_mut().find(|e| e.path == "describe.json") {
entry.bytes.clone_from(&final_describe_bytes);
}
let zip_bytes = build_gtxpack_with_manifest(entries)
.map_err(|e| anyhow::anyhow!("build_gtxpack_with_manifest: {e}"))?;
verify_produced_pack(&zip_bytes, &describe_typed, signing_key)?;
std::fs::write(output_pack, &zip_bytes)?;
let size = u64::try_from(zip_bytes.len()).unwrap_or(u64::MAX);
let pack_name = output_pack
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("pack.gtxpack")
.to_string();
let sha256 = sha256_hex(&zip_bytes);
Ok(PackInfo {
pack_path: output_pack.to_path_buf(),
pack_name,
size,
sha256,
ext_name,
ext_version,
ext_kind,
describe_bytes: final_describe_bytes,
})
}
fn verify_produced_pack(
zip_bytes: &[u8],
describe: &DescribeJson,
signing_key: Option<&ed25519_dalek::SigningKey>,
) -> anyhow::Result<()> {
let manifest_bytes = read_manifest_from_zip(zip_bytes)?;
greentic_extension_sdk_contract::verify_archive_against_manifest(zip_bytes)
.map_err(|e| anyhow::anyhow!("self-verify (archive vs manifest): {e}"))?;
greentic_extension_sdk_contract::verify_manifest_binding(describe, &manifest_bytes)
.map_err(|e| anyhow::anyhow!("self-verify (manifest binding): {e}"))?;
if let Some(key) = signing_key {
greentic_extension_sdk_contract::verify_describe_with_key(describe, &key.verifying_key())
.map_err(|e| anyhow::anyhow!("self-verify (signature): {e}"))?;
}
Ok(())
}
#[cfg(test)]
mod tests;