#![forbid(unsafe_code)]
use std::io::{Cursor, Read as _};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::runtime::Handle;
use crate::extension_refs::{
ExtensionDependency, ExtensionDependencySource, PackExtensionsFile, read_extensions_file,
};
const STORE_URL_ENV: &str = "GREENTIC_STORE_URL";
#[derive(Debug, Clone)]
pub struct ExtRef {
pub extension_id: String,
}
pub fn parse_ext_ref(raw: &str) -> Result<ExtRef> {
let rest = raw.strip_prefix("ext://").ok_or_else(|| {
anyhow::anyhow!("ext:// component ref must start with 'ext://' (got '{raw}')")
})?;
let (id, fragment) = rest.split_once('#').ok_or_else(|| {
anyhow::anyhow!(
"ext:// component ref must have the form 'ext://<id>#component' (got '{raw}')"
)
})?;
if fragment != "component" {
bail!("ext:// component ref fragment must be '#component' (got '#{fragment}')");
}
if id.trim().is_empty() {
bail!("ext:// component ref extension id must not be empty (got '{raw}')");
}
Ok(ExtRef {
extension_id: id.to_string(),
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GtxpackComponentSidecar {
pub component: GtxpackComponentEntry,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GtxpackComponentEntry {
pub id: String,
pub asset: String,
pub digest: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoreRef {
pub name: String,
pub version: String,
}
pub fn parse_store_ref(raw: &str) -> Result<StoreRef> {
let rest = raw.strip_prefix("store://").ok_or_else(|| {
anyhow!("store:// extension ref must start with 'store://' (got '{raw}')")
})?;
let (name, version) = rest.split_once('@').ok_or_else(|| {
anyhow!(
"store:// extension ref must pin a version as 'store://<name>@<version>' (got '{raw}')"
)
})?;
if name.trim().is_empty() {
bail!("store:// extension ref name must not be empty (got '{raw}')");
}
if version.trim().is_empty() {
bail!("store:// extension ref must pin a non-empty version (got '{raw}')");
}
Ok(StoreRef {
name: name.to_string(),
version: version.to_string(),
})
}
pub fn resolve_ext_component(pack_dir: &Path, raw_ref: &str) -> Result<(Vec<u8>, String)> {
let (ext_ref, dep) = lookup_ext_dependency(pack_dir, raw_ref)?;
let zip_bytes = read_local_extension_source(&dep.source)
.with_context(|| format!("resolve source for extension '{}'", dep.id))?;
extract_and_verify_bytes(&ext_ref.extension_id, &zip_bytes)
}
pub fn resolve_ext_component_with_dist(
pack_dir: &Path,
raw_ref: &str,
cache_dir: &Path,
offline: bool,
handle: Option<&Handle>,
) -> Result<(Vec<u8>, String)> {
let (ext_ref, dep) = lookup_ext_dependency(pack_dir, raw_ref)?;
let zip_bytes = acquire_extension_bytes(&dep.source, cache_dir, offline, handle)
.with_context(|| format!("acquire source for extension '{}'", dep.id))?;
extract_and_verify_bytes(&ext_ref.extension_id, &zip_bytes)
}
fn lookup_ext_dependency(pack_dir: &Path, raw_ref: &str) -> Result<(ExtRef, ExtensionDependency)> {
let ext_ref = parse_ext_ref(raw_ref)?;
let extensions_path = pack_dir.join("pack.extensions.json");
let extensions = read_extensions_file(&extensions_path)
.with_context(|| format!("read pack.extensions.json from {}", pack_dir.display()))?;
let dep = find_extension_dep(&extensions, &ext_ref.extension_id)
.with_context(|| {
format!(
"ext:// component ref names extension '{}' not declared in pack.extensions.json",
ext_ref.extension_id
)
})?
.clone();
Ok((ext_ref, dep))
}
fn find_extension_dep<'a>(
file: &'a PackExtensionsFile,
id: &str,
) -> Option<&'a ExtensionDependency> {
file.extensions.iter().find(|dep| dep.id == id)
}
fn read_local_extension_source(source: &ExtensionDependencySource) -> Result<Vec<u8>> {
let raw = source.reference.as_str();
if let Some(path) = local_path_for_source(raw) {
return std::fs::read(&path)
.with_context(|| format!("read extension .gtxpack at {}", path.display()));
}
bail!(
"ext:// component resolver here only supports file:// or bare local extension sources, got '{raw}' (use the dist-aware resolver for store://)"
);
}
fn local_path_for_source(raw: &str) -> Option<PathBuf> {
if let Some(path_str) = raw.strip_prefix("file://") {
return Some(PathBuf::from(path_str));
}
if !raw.contains("://") {
return Some(PathBuf::from(raw));
}
None
}
fn acquire_extension_bytes(
source: &ExtensionDependencySource,
cache_dir: &Path,
offline: bool,
_handle: Option<&Handle>,
) -> Result<Vec<u8>> {
let raw = source.reference.as_str();
if local_path_for_source(raw).is_some() {
return read_local_extension_source(source);
}
if raw.starts_with("store://") {
let store_ref = parse_store_ref(raw)?;
return acquire_store_extension_bytes(&store_ref, cache_dir, offline);
}
if raw.starts_with("oci://") {
bail!(
"oci:// extension acquisition not yet supported (no producer); declare the extension with a store:// or file:// source instead (got '{raw}')"
);
}
bail!("unsupported extension source scheme for ext:// resolution: '{raw}'");
}
fn acquire_store_extension_bytes(
store_ref: &StoreRef,
cache_dir: &Path,
offline: bool,
) -> Result<Vec<u8>> {
let store_base = if offline {
String::new()
} else {
std::env::var(STORE_URL_ENV).map_err(|_| {
anyhow!(
"{STORE_URL_ENV} is not set; it must name the store base URL to acquire store:// extension '{}@{}'",
store_ref.name,
store_ref.version
)
})?
};
greentic_distributor_client::store_ext::fetch_store_extension(
&store_base,
&store_ref.name,
&store_ref.version,
cache_dir,
offline,
)
.with_context(|| {
format!(
"acquire store extension '{}@{}'",
store_ref.name, store_ref.version
)
})
}
pub fn extract_and_verify_bytes(extension_id: &str, zip_bytes: &[u8]) -> Result<(Vec<u8>, String)> {
let cursor = Cursor::new(zip_bytes);
let mut archive = zip::ZipArchive::new(cursor)
.with_context(|| format!("open extension .gtxpack ZIP for '{extension_id}'"))?;
let sidecar: GtxpackComponentSidecar = {
let mut entry = archive.by_name("component.json").map_err(|_| {
anyhow!(
"extension '{extension_id}' does not embed a runtime component: 'component.json' not found in .gtxpack"
)
})?;
let mut buf = Vec::new();
entry
.read_to_end(&mut buf)
.with_context(|| format!("read component.json for '{extension_id}'"))?;
serde_json::from_slice(&buf)
.with_context(|| format!("parse component.json for '{extension_id}'"))?
};
let asset_path = sidecar.component.asset.as_str();
if asset_path.trim().is_empty() {
bail!(
"extension '{extension_id}' does not embed a runtime component: 'component.json' component.asset is empty"
);
}
let wasm_bytes = {
let mut entry = archive.by_name(asset_path).map_err(|_| {
anyhow!(
"extension '{extension_id}' does not embed a runtime component: asset '{asset_path}' not found in .gtxpack"
)
})?;
let mut buf = Vec::new();
entry
.read_to_end(&mut buf)
.with_context(|| format!("read asset '{asset_path}' for '{extension_id}'"))?;
buf
};
let actual_digest = format!("sha256:{}", hex::encode(Sha256::digest(&wasm_bytes)));
let expected_digest = sidecar.component.digest.as_str();
if actual_digest != expected_digest {
bail!(
"embedded component digest mismatch for extension '{extension_id}': component.json advertises '{expected_digest}' but extracted wasm hashes to '{actual_digest}'"
);
}
Ok((wasm_bytes, actual_digest))
}
pub fn read_describe_from_gtxpack(extension_id: &str, zip_bytes: &[u8]) -> Result<Vec<u8>> {
let cursor = Cursor::new(zip_bytes);
let mut archive = zip::ZipArchive::new(cursor)
.with_context(|| format!("open extension .gtxpack ZIP for '{extension_id}'"))?;
let mut file = archive
.by_name("describe.json")
.with_context(|| format!("extension '{extension_id}' .gtxpack has no describe.json"))?;
let mut body = Vec::new();
file.read_to_end(&mut body)
.with_context(|| format!("read describe.json from '{extension_id}' .gtxpack"))?;
Ok(body)
}
pub fn resolve_agent_tool_requirements(
pack_dir: &Path,
agents: &std::collections::BTreeMap<String, serde_json::Value>,
cache_dir: &Path,
offline: bool,
) -> Result<std::collections::BTreeMap<String, Vec<crate::setup_gen::ToolSecretReq>>> {
use std::collections::{BTreeMap, BTreeSet};
let mut used: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for (agent_name, agent) in agents {
let Some(tools) = agent.get("tools").and_then(|t| t.as_array()) else {
continue;
};
for tool in tools {
let (Some(ext_id), Some(tool_name)) = (
tool.get("extension_id").and_then(|e| e.as_str()),
tool.get("tool_name").and_then(|n| n.as_str()),
) else {
tracing::warn!(
agent = %agent_name,
"skipping malformed agent tool entry: missing extension_id or tool_name"
);
continue;
};
used.entry(ext_id.to_string())
.or_default()
.insert(tool_name.to_string());
}
}
let mut out = BTreeMap::new();
for (ext_id, tool_names) in &used {
let raw_ref = format!("ext://{ext_id}#component");
let (_ext_ref, dep) = lookup_ext_dependency(pack_dir, &raw_ref).with_context(|| {
format!("resolve tool extension '{ext_id}' for credential form generation")
})?;
let zip_bytes = acquire_extension_bytes(&dep.source, cache_dir, offline, None)
.with_context(|| format!("acquire .gtxpack for tool extension '{ext_id}'"))?;
let describe_bytes = read_describe_from_gtxpack(ext_id, &zip_bytes)?;
let names: Vec<String> = tool_names.iter().cloned().collect();
let secret_requirements =
crate::setup_gen::extract_tool_secret_requirements(&describe_bytes, &names)?;
out.insert(ext_id.clone(), secret_requirements);
}
Ok(out)
}
#[cfg(test)]
mod describe_tests {
use super::*;
use std::io::Write;
fn gtxpack_with_describe(describe: &str) -> Vec<u8> {
let mut buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
zip.start_file("describe.json", zip::write::FileOptions::<()>::default())
.unwrap();
zip.write_all(describe.as_bytes()).unwrap();
zip.finish().unwrap();
}
buf
}
#[test]
fn reads_describe_json_entry_from_gtxpack() {
let bytes = gtxpack_with_describe(r#"{"contributions":{"tools":[]}}"#);
let body = read_describe_from_gtxpack("greentic.tavily", &bytes).unwrap();
assert!(String::from_utf8_lossy(&body).contains("contributions"));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_ext_ref_happy() {
let r = parse_ext_ref("ext://greentic.http#component").expect("valid ref");
assert_eq!(r.extension_id, "greentic.http");
}
#[test]
fn parse_ext_ref_wrong_scheme() {
let err = parse_ext_ref("oci://foo#component").expect_err("wrong scheme");
assert!(err.to_string().contains("ext://"));
}
#[test]
fn parse_ext_ref_no_fragment() {
let err = parse_ext_ref("ext://greentic.http").expect_err("no fragment");
assert!(err.to_string().contains("#component"));
}
#[test]
fn parse_ext_ref_wrong_fragment() {
let err = parse_ext_ref("ext://greentic.http#other").expect_err("wrong fragment");
assert!(err.to_string().contains("#component"));
}
#[test]
fn parse_ext_ref_empty_id() {
let err = parse_ext_ref("ext://#component").expect_err("empty id");
assert!(err.to_string().contains("must not be empty"));
}
}