1#![forbid(unsafe_code)]
41
42use std::io::{Cursor, Read as _};
43use std::path::{Path, PathBuf};
44
45use anyhow::{Context, Result, anyhow, bail};
46use serde::{Deserialize, Serialize};
47use sha2::{Digest, Sha256};
48use tokio::runtime::Handle;
49
50use crate::extension_refs::{
51 ExtensionDependency, ExtensionDependencySource, PackExtensionsFile, read_extensions_file,
52};
53
54const STORE_URL_ENV: &str = "GREENTIC_STORE_URL";
57
58#[derive(Debug, Clone)]
60pub struct ExtRef {
61 pub extension_id: String,
63}
64
65pub fn parse_ext_ref(raw: &str) -> Result<ExtRef> {
70 let rest = raw.strip_prefix("ext://").ok_or_else(|| {
71 anyhow::anyhow!("ext:// component ref must start with 'ext://' (got '{raw}')")
72 })?;
73 let (id, fragment) = rest.split_once('#').ok_or_else(|| {
74 anyhow::anyhow!(
75 "ext:// component ref must have the form 'ext://<id>#component' (got '{raw}')"
76 )
77 })?;
78 if fragment != "component" {
79 bail!("ext:// component ref fragment must be '#component' (got '#{fragment}')");
80 }
81 if id.trim().is_empty() {
82 bail!("ext:// component ref extension id must not be empty (got '{raw}')");
83 }
84 Ok(ExtRef {
85 extension_id: id.to_string(),
86 })
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct GtxpackComponentSidecar {
92 pub component: GtxpackComponentEntry,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct GtxpackComponentEntry {
99 pub id: String,
101 pub asset: String,
103 pub digest: String,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct StoreRef {
110 pub name: String,
112 pub version: String,
114}
115
116pub fn parse_store_ref(raw: &str) -> Result<StoreRef> {
121 let rest = raw.strip_prefix("store://").ok_or_else(|| {
122 anyhow!("store:// extension ref must start with 'store://' (got '{raw}')")
123 })?;
124 let (name, version) = rest.split_once('@').ok_or_else(|| {
125 anyhow!(
126 "store:// extension ref must pin a version as 'store://<name>@<version>' (got '{raw}')"
127 )
128 })?;
129 if name.trim().is_empty() {
130 bail!("store:// extension ref name must not be empty (got '{raw}')");
131 }
132 if version.trim().is_empty() {
133 bail!("store:// extension ref must pin a non-empty version (got '{raw}')");
134 }
135 Ok(StoreRef {
136 name: name.to_string(),
137 version: version.to_string(),
138 })
139}
140
141pub fn resolve_ext_component(pack_dir: &Path, raw_ref: &str) -> Result<(Vec<u8>, String)> {
151 let (ext_ref, dep) = lookup_ext_dependency(pack_dir, raw_ref)?;
152 let zip_bytes = read_local_extension_source(&dep.source)
153 .with_context(|| format!("resolve source for extension '{}'", dep.id))?;
154 extract_and_verify_bytes(&ext_ref.extension_id, &zip_bytes)
155}
156
157pub fn resolve_ext_component_with_dist(
167 pack_dir: &Path,
168 raw_ref: &str,
169 cache_dir: &Path,
170 offline: bool,
171 handle: Option<&Handle>,
172) -> Result<(Vec<u8>, String)> {
173 let (ext_ref, dep) = lookup_ext_dependency(pack_dir, raw_ref)?;
174 let zip_bytes = acquire_extension_bytes(&dep.source, cache_dir, offline, handle)
175 .with_context(|| format!("acquire source for extension '{}'", dep.id))?;
176 extract_and_verify_bytes(&ext_ref.extension_id, &zip_bytes)
177}
178
179fn lookup_ext_dependency(pack_dir: &Path, raw_ref: &str) -> Result<(ExtRef, ExtensionDependency)> {
182 let ext_ref = parse_ext_ref(raw_ref)?;
183 let extensions_path = pack_dir.join("pack.extensions.json");
184 let extensions = read_extensions_file(&extensions_path)
185 .with_context(|| format!("read pack.extensions.json from {}", pack_dir.display()))?;
186 let dep = find_extension_dep(&extensions, &ext_ref.extension_id)
187 .with_context(|| {
188 format!(
189 "ext:// component ref names extension '{}' not declared in pack.extensions.json",
190 ext_ref.extension_id
191 )
192 })?
193 .clone();
194 Ok((ext_ref, dep))
195}
196
197fn find_extension_dep<'a>(
198 file: &'a PackExtensionsFile,
199 id: &str,
200) -> Option<&'a ExtensionDependency> {
201 file.extensions.iter().find(|dep| dep.id == id)
202}
203
204fn read_local_extension_source(source: &ExtensionDependencySource) -> Result<Vec<u8>> {
209 let raw = source.reference.as_str();
210 if let Some(path) = local_path_for_source(raw) {
211 return std::fs::read(&path)
212 .with_context(|| format!("read extension .gtxpack at {}", path.display()));
213 }
214 bail!(
215 "ext:// component resolver here only supports file:// or bare local extension sources, got '{raw}' (use the dist-aware resolver for store://)"
216 );
217}
218
219fn local_path_for_source(raw: &str) -> Option<PathBuf> {
221 if let Some(path_str) = raw.strip_prefix("file://") {
222 return Some(PathBuf::from(path_str));
223 }
224 if !raw.contains("://") {
225 return Some(PathBuf::from(raw));
226 }
227 None
228}
229
230fn acquire_extension_bytes(
236 source: &ExtensionDependencySource,
237 cache_dir: &Path,
238 offline: bool,
239 _handle: Option<&Handle>,
240) -> Result<Vec<u8>> {
241 let raw = source.reference.as_str();
242 if local_path_for_source(raw).is_some() {
243 return read_local_extension_source(source);
244 }
245 if raw.starts_with("store://") {
246 let store_ref = parse_store_ref(raw)?;
247 return acquire_store_extension_bytes(&store_ref, cache_dir, offline);
248 }
249 if raw.starts_with("oci://") {
250 bail!(
254 "oci:// extension acquisition not yet supported (no producer); declare the extension with a store:// or file:// source instead (got '{raw}')"
255 );
256 }
257 bail!("unsupported extension source scheme for ext:// resolution: '{raw}'");
258}
259
260fn acquire_store_extension_bytes(
268 store_ref: &StoreRef,
269 cache_dir: &Path,
270 offline: bool,
271) -> Result<Vec<u8>> {
272 let store_base = if offline {
275 String::new()
276 } else {
277 std::env::var(STORE_URL_ENV).map_err(|_| {
278 anyhow!(
279 "{STORE_URL_ENV} is not set; it must name the store base URL to acquire store:// extension '{}@{}'",
280 store_ref.name,
281 store_ref.version
282 )
283 })?
284 };
285
286 greentic_distributor_client::store_ext::fetch_store_extension(
287 &store_base,
288 &store_ref.name,
289 &store_ref.version,
290 cache_dir,
291 offline,
292 )
293 .with_context(|| {
294 format!(
295 "acquire store extension '{}@{}'",
296 store_ref.name, store_ref.version
297 )
298 })
299}
300
301pub fn extract_and_verify_bytes(extension_id: &str, zip_bytes: &[u8]) -> Result<(Vec<u8>, String)> {
304 let cursor = Cursor::new(zip_bytes);
305 let mut archive = zip::ZipArchive::new(cursor)
306 .with_context(|| format!("open extension .gtxpack ZIP for '{extension_id}'"))?;
307
308 let sidecar: GtxpackComponentSidecar = {
310 let mut entry = archive.by_name("component.json").map_err(|_| {
311 anyhow!(
312 "extension '{extension_id}' does not embed a runtime component: 'component.json' not found in .gtxpack"
313 )
314 })?;
315 let mut buf = Vec::new();
316 entry
317 .read_to_end(&mut buf)
318 .with_context(|| format!("read component.json for '{extension_id}'"))?;
319 serde_json::from_slice(&buf)
320 .with_context(|| format!("parse component.json for '{extension_id}'"))?
321 };
322
323 let asset_path = sidecar.component.asset.as_str();
325 if asset_path.trim().is_empty() {
326 bail!(
327 "extension '{extension_id}' does not embed a runtime component: 'component.json' component.asset is empty"
328 );
329 }
330
331 let wasm_bytes = {
333 let mut entry = archive.by_name(asset_path).map_err(|_| {
334 anyhow!(
335 "extension '{extension_id}' does not embed a runtime component: asset '{asset_path}' not found in .gtxpack"
336 )
337 })?;
338 let mut buf = Vec::new();
339 entry
340 .read_to_end(&mut buf)
341 .with_context(|| format!("read asset '{asset_path}' for '{extension_id}'"))?;
342 buf
343 };
344
345 let actual_digest = format!("sha256:{}", hex::encode(Sha256::digest(&wasm_bytes)));
347
348 let expected_digest = sidecar.component.digest.as_str();
350 if actual_digest != expected_digest {
351 bail!(
352 "embedded component digest mismatch for extension '{extension_id}': component.json advertises '{expected_digest}' but extracted wasm hashes to '{actual_digest}'"
353 );
354 }
355
356 Ok((wasm_bytes, actual_digest))
357}
358
359pub fn read_describe_from_gtxpack(extension_id: &str, zip_bytes: &[u8]) -> Result<Vec<u8>> {
364 let cursor = Cursor::new(zip_bytes);
365 let mut archive = zip::ZipArchive::new(cursor)
366 .with_context(|| format!("open extension .gtxpack ZIP for '{extension_id}'"))?;
367 let mut file = archive
368 .by_name("describe.json")
369 .with_context(|| format!("extension '{extension_id}' .gtxpack has no describe.json"))?;
370 let mut body = Vec::new();
371 file.read_to_end(&mut body)
372 .with_context(|| format!("read describe.json from '{extension_id}' .gtxpack"))?;
373 Ok(body)
374}
375
376pub fn resolve_agent_tool_requirements(
386 pack_dir: &Path,
387 agents: &std::collections::BTreeMap<String, serde_json::Value>,
388 cache_dir: &Path,
389 offline: bool,
390) -> Result<std::collections::BTreeMap<String, Vec<crate::setup_gen::ToolSecretReq>>> {
391 use std::collections::{BTreeMap, BTreeSet};
392
393 let mut used: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
395 for (agent_name, agent) in agents {
396 let Some(tools) = agent.get("tools").and_then(|t| t.as_array()) else {
397 continue;
398 };
399 for tool in tools {
400 let (Some(ext_id), Some(tool_name)) = (
401 tool.get("extension_id").and_then(|e| e.as_str()),
402 tool.get("tool_name").and_then(|n| n.as_str()),
403 ) else {
404 tracing::warn!(
405 agent = %agent_name,
406 "skipping malformed agent tool entry: missing extension_id or tool_name"
407 );
408 continue;
409 };
410 used.entry(ext_id.to_string())
411 .or_default()
412 .insert(tool_name.to_string());
413 }
414 }
415
416 let mut out = BTreeMap::new();
417 for (ext_id, tool_names) in &used {
418 let raw_ref = format!("ext://{ext_id}#component");
421 let (_ext_ref, dep) = lookup_ext_dependency(pack_dir, &raw_ref).with_context(|| {
422 format!("resolve tool extension '{ext_id}' for credential form generation")
423 })?;
424 let zip_bytes = acquire_extension_bytes(&dep.source, cache_dir, offline, None)
425 .with_context(|| format!("acquire .gtxpack for tool extension '{ext_id}'"))?;
426 let describe_bytes = read_describe_from_gtxpack(ext_id, &zip_bytes)?;
427 let names: Vec<String> = tool_names.iter().cloned().collect();
428 let secret_requirements =
429 crate::setup_gen::extract_tool_secret_requirements(&describe_bytes, &names)?;
430 out.insert(ext_id.clone(), secret_requirements);
431 }
432 Ok(out)
433}
434
435#[cfg(test)]
436mod describe_tests {
437 use super::*;
438 use std::io::Write;
439
440 fn gtxpack_with_describe(describe: &str) -> Vec<u8> {
441 let mut buf = Vec::new();
442 {
443 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
444 zip.start_file("describe.json", zip::write::FileOptions::<()>::default())
445 .unwrap();
446 zip.write_all(describe.as_bytes()).unwrap();
447 zip.finish().unwrap();
448 }
449 buf
450 }
451
452 #[test]
453 fn reads_describe_json_entry_from_gtxpack() {
454 let bytes = gtxpack_with_describe(r#"{"contributions":{"tools":[]}}"#);
455 let body = read_describe_from_gtxpack("greentic.tavily", &bytes).unwrap();
456 assert!(String::from_utf8_lossy(&body).contains("contributions"));
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 #[test]
465 fn parse_ext_ref_happy() {
466 let r = parse_ext_ref("ext://greentic.http#component").expect("valid ref");
467 assert_eq!(r.extension_id, "greentic.http");
468 }
469
470 #[test]
471 fn parse_ext_ref_wrong_scheme() {
472 let err = parse_ext_ref("oci://foo#component").expect_err("wrong scheme");
473 assert!(err.to_string().contains("ext://"));
474 }
475
476 #[test]
477 fn parse_ext_ref_no_fragment() {
478 let err = parse_ext_ref("ext://greentic.http").expect_err("no fragment");
479 assert!(err.to_string().contains("#component"));
480 }
481
482 #[test]
483 fn parse_ext_ref_wrong_fragment() {
484 let err = parse_ext_ref("ext://greentic.http#other").expect_err("wrong fragment");
485 assert!(err.to_string().contains("#component"));
486 }
487
488 #[test]
489 fn parse_ext_ref_empty_id() {
490 let err = parse_ext_ref("ext://#component").expect_err("empty id");
491 assert!(err.to_string().contains("must not be empty"));
492 }
493}