Skip to main content

packc/cli/
ext_resolver.rs

1//! Helper for resolving `ext://<id>#component` references.
2//!
3//! # Resolution flow
4//!
5//! 1. Parse the `ext://<id>#component` ref — error if malformed.
6//! 2. Look up `<id>` in `pack.extensions.json` via [`read_extensions_file`].
7//! 3. Acquire the extension's `.gtxpack` from its declared `file://` or bare local source.
8//! 4. Read `component.json` from the ZIP to obtain the component asset path and expected digest.
9//! 5. Read the component wasm asset from the ZIP.
10//! 6. Verify SHA-256 digest — error on mismatch.
11//! 7. Return the extracted bytes.
12//!
13//! # `component.json` schema (Phase 2 sidecar)
14//!
15//! The resolver reads a packc-owned `component.json` sidecar at the root of the
16//! `.gtxpack` ZIP. It is written alongside the canonical, store-validated `describe.json`
17//! (describe-v2) manifest, which itself cannot carry this metadata (its schema root is
18//! `additionalProperties: false`).
19//!
20//! ```json
21//! {
22//!   "component": {
23//!     "id": "greentic.component-http",
24//!     "asset": "component.wasm",
25//!     "digest": "sha256:<hex>"
26//!   }
27//! }
28//! ```
29//!
30//! Fields:
31//! - `component.id`     — the store id; informational (the resolver does not enforce
32//!   `id == extension_id`).
33//! - `component.asset`  — ZIP entry name of the runtime wasm. For a `ComponentExtension`
34//!   producer this is `component.wasm` at the root; other producers may use arbitrary
35//!   paths such as `assets/component-<name>.wasm`.
36//! - `component.digest` — `sha256:<hex>` digest of the wasm bytes.
37//!
38//! The Phase-2 producer (`greentic-component store publish`) must emit exactly this shape.
39
40#![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
54/// Environment variable naming the store base URL used to acquire `store://`
55/// extension artifacts (same env the Phase-2 producer publishes against).
56const STORE_URL_ENV: &str = "GREENTIC_STORE_URL";
57
58/// Parsed form of `ext://<id>#component`.
59#[derive(Debug, Clone)]
60pub struct ExtRef {
61    /// Extension id (the `<id>` segment).
62    pub extension_id: String,
63}
64
65/// Parse an `ext://<id>#component` reference.
66///
67/// Returns an error if the ref is malformed (wrong scheme, missing `#component` fragment,
68/// or empty id).
69pub 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/// Embedded-component descriptor from the `component.json` sidecar inside a `.gtxpack` ZIP.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct GtxpackComponentSidecar {
92    /// Embedded runtime component descriptor.
93    pub component: GtxpackComponentEntry,
94}
95
96/// Single embedded component entry in `component.json`.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct GtxpackComponentEntry {
99    /// Extension id — should match `pack.extensions.json`.
100    pub id: String,
101    /// Path inside the ZIP to the runtime wasm asset (e.g. `assets/component-foo.wasm`).
102    pub asset: String,
103    /// SHA-256 digest of the wasm bytes (`sha256:<hex>`).
104    pub digest: String,
105}
106
107/// Parsed form of a `store://<name>@<version>` extension source ref.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct StoreRef {
110    /// Extension name (the `<name>` segment).
111    pub name: String,
112    /// Explicit version (the `<version>` segment). Required in Phase 3a.
113    pub version: String,
114}
115
116/// Parse a `store://<name>@<version>` extension source ref.
117///
118/// Phase 3a requires an explicit version; tag/latest resolution is out of scope,
119/// so a missing or empty version is an error.
120pub 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
141/// Resolve an `ext://<id>#component` reference by extracting the wasm from the extension's
142/// `.gtxpack` and verifying the digest against the `component.json` sidecar.
143///
144/// `pack_dir` is the directory containing `pack.extensions.json`.
145///
146/// This file://-only entry point keeps the Phase-1 signature; network schemes
147/// (`store://`/`oci://`) require [`resolve_ext_component_with_dist`].
148///
149/// Returns the raw wasm bytes and the verified digest string (`sha256:<hex>`).
150pub 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
157/// Cache/handle-aware entry point that resolves an `ext://<id>#component` ref,
158/// acquiring the extension `.gtxpack` over the network when its declared source
159/// is `store://` (and, guarded, `oci://`). `file://`/bare sources behave exactly
160/// as [`resolve_ext_component`].
161///
162/// - `cache_dir` is the runtime cache dir (downloaded artifacts are cached under it).
163/// - `offline` disables network fetches and forces cache-only resolution.
164/// - `handle` is an optional current Tokio runtime handle; the store path uses
165///   blocking `reqwest` on a dedicated thread, so it does not require one.
166pub 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
179/// Parse the `ext://` ref and locate the matching dependency in
180/// `pack.extensions.json`.
181fn 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
204/// Read a `file://` or bare-path extension source into ZIP bytes.
205///
206/// Network schemes are rejected here; callers needing them must use
207/// [`acquire_extension_bytes`].
208fn 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
219/// Map a `file://` or bare-path ref to a filesystem path; `None` for any scheme.
220fn 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
230/// Acquire the extension `.gtxpack` bytes for any supported source scheme.
231///
232/// - `file://`/bare → filesystem read (unchanged from Phase 1).
233/// - `store://`     → store artifact endpoint GET, cached by archive sha256.
234/// - `oci://`       → guarded/deferred (no producer yet) → clear error.
235fn 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        // No producer publishes extensions to OCI yet; the DistClient is
251        // wasm/pack media-type centric and would likely reject a `.gtxpack`.
252        // Bail with a clear, actionable message rather than a fragile path.
253        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
260/// Acquire (and cache) the extension `.gtxpack` from the store artifact endpoint.
261///
262/// Reference resolution (the `store://<name>@<version>` parse and the
263/// `GREENTIC_STORE_URL` base lookup) stays here; the HTTP transport, digest
264/// verification, and on-disk caching are delegated to
265/// [`greentic_distributor_client::store_ext::fetch_store_extension`] so that
266/// packs and store extensions share a single fetch path.
267fn acquire_store_extension_bytes(
268    store_ref: &StoreRef,
269    cache_dir: &Path,
270    offline: bool,
271) -> Result<Vec<u8>> {
272    // Offline never hits the network, so the store base URL is not needed (and
273    // we must not require the env var). Online, resolve it from the environment.
274    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
301/// Read the `component.json` sidecar + the component wasm asset from `.gtxpack`
302/// ZIP `zip_bytes`, verify the digest, and return (wasm_bytes, verified_digest).
303pub 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    // Read the component.json sidecar.
309    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    // Validate the component entry.
324    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    // Read the wasm asset.
332    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    // Compute actual digest.
346    let actual_digest = format!("sha256:{}", hex::encode(Sha256::digest(&wasm_bytes)));
347
348    // Verify against the component.json advertised digest.
349    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
359/// Read the `describe.json` sidecar from a `.gtxpack` ZIP.
360///
361/// Returns the raw `describe.json` bytes so callers can parse only the fields
362/// they need (e.g. via [`crate::setup_gen::extract_tool_secret_requirements`]).
363pub 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
376/// For each tool extension used by the agents, acquire its `.gtxpack`, read
377/// `describe.json`, and extract the secret requirements of the used tools.
378///
379/// Returns a map keyed by extension id. Errors (and propagates via `?`) when a
380/// declared tool extension is not found in `pack.extensions.json` or cannot be
381/// acquired — no silent skips.
382///
383/// `pack_dir` must contain `pack.extensions.json`. `cache_dir` and `offline`
384/// are threaded through to [`acquire_extension_bytes`] for `store://` sources.
385pub 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    // Collect extension_id -> set(tool_name) actually used across all agents.
394    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        // Reuse lookup_ext_dependency — it requires the #component fragment for
419        // the parse_ext_ref validator even though we only need describe.json.
420        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}