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};
44use std::time::Duration;
45
46use anyhow::{Context, Result, anyhow, bail};
47use serde::{Deserialize, Serialize};
48use sha2::{Digest, Sha256};
49use tokio::runtime::Handle;
50
51use crate::extension_refs::{
52    ExtensionDependency, ExtensionDependencySource, PackExtensionsFile, read_extensions_file,
53};
54
55/// Environment variable naming the store base URL used to acquire `store://`
56/// extension artifacts (same env the Phase-2 producer publishes against).
57const STORE_URL_ENV: &str = "GREENTIC_STORE_URL";
58
59/// Parsed form of `ext://<id>#component`.
60#[derive(Debug, Clone)]
61pub struct ExtRef {
62    /// Extension id (the `<id>` segment).
63    pub extension_id: String,
64}
65
66/// Parse an `ext://<id>#component` reference.
67///
68/// Returns an error if the ref is malformed (wrong scheme, missing `#component` fragment,
69/// or empty id).
70pub fn parse_ext_ref(raw: &str) -> Result<ExtRef> {
71    let rest = raw.strip_prefix("ext://").ok_or_else(|| {
72        anyhow::anyhow!("ext:// component ref must start with 'ext://' (got '{raw}')")
73    })?;
74    let (id, fragment) = rest.split_once('#').ok_or_else(|| {
75        anyhow::anyhow!(
76            "ext:// component ref must have the form 'ext://<id>#component' (got '{raw}')"
77        )
78    })?;
79    if fragment != "component" {
80        bail!("ext:// component ref fragment must be '#component' (got '#{fragment}')");
81    }
82    if id.trim().is_empty() {
83        bail!("ext:// component ref extension id must not be empty (got '{raw}')");
84    }
85    Ok(ExtRef {
86        extension_id: id.to_string(),
87    })
88}
89
90/// Embedded-component descriptor from the `component.json` sidecar inside a `.gtxpack` ZIP.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct GtxpackComponentSidecar {
93    /// Embedded runtime component descriptor.
94    pub component: GtxpackComponentEntry,
95}
96
97/// Single embedded component entry in `component.json`.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct GtxpackComponentEntry {
100    /// Extension id — should match `pack.extensions.json`.
101    pub id: String,
102    /// Path inside the ZIP to the runtime wasm asset (e.g. `assets/component-foo.wasm`).
103    pub asset: String,
104    /// SHA-256 digest of the wasm bytes (`sha256:<hex>`).
105    pub digest: String,
106}
107
108/// Parsed form of a `store://<name>@<version>` extension source ref.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct StoreRef {
111    /// Extension name (the `<name>` segment).
112    pub name: String,
113    /// Explicit version (the `<version>` segment). Required in Phase 3a.
114    pub version: String,
115}
116
117/// Parse a `store://<name>@<version>` extension source ref.
118///
119/// Phase 3a requires an explicit version; tag/latest resolution is out of scope,
120/// so a missing or empty version is an error.
121pub fn parse_store_ref(raw: &str) -> Result<StoreRef> {
122    let rest = raw.strip_prefix("store://").ok_or_else(|| {
123        anyhow!("store:// extension ref must start with 'store://' (got '{raw}')")
124    })?;
125    let (name, version) = rest.split_once('@').ok_or_else(|| {
126        anyhow!(
127            "store:// extension ref must pin a version as 'store://<name>@<version>' (got '{raw}')"
128        )
129    })?;
130    if name.trim().is_empty() {
131        bail!("store:// extension ref name must not be empty (got '{raw}')");
132    }
133    if version.trim().is_empty() {
134        bail!("store:// extension ref must pin a non-empty version (got '{raw}')");
135    }
136    Ok(StoreRef {
137        name: name.to_string(),
138        version: version.to_string(),
139    })
140}
141
142/// Build the store artifact endpoint URL for an extension `(name, version)`.
143///
144/// Shape: `{base}/api/v1/extensions/{name}/{version}/artifact` (public, no auth).
145pub fn store_artifact_url(store_base: &str, name: &str, version: &str) -> String {
146    let base = store_base.trim_end_matches('/');
147    format!("{base}/api/v1/extensions/{name}/{version}/artifact")
148}
149
150/// Resolve an `ext://<id>#component` reference by extracting the wasm from the extension's
151/// `.gtxpack` and verifying the digest against the `component.json` sidecar.
152///
153/// `pack_dir` is the directory containing `pack.extensions.json`.
154///
155/// This file://-only entry point keeps the Phase-1 signature; network schemes
156/// (`store://`/`oci://`) require [`resolve_ext_component_with_dist`].
157///
158/// Returns the raw wasm bytes and the verified digest string (`sha256:<hex>`).
159pub fn resolve_ext_component(pack_dir: &Path, raw_ref: &str) -> Result<(Vec<u8>, String)> {
160    let (ext_ref, dep) = lookup_ext_dependency(pack_dir, raw_ref)?;
161    let zip_bytes = read_local_extension_source(&dep.source)
162        .with_context(|| format!("resolve source for extension '{}'", dep.id))?;
163    extract_and_verify_bytes(&ext_ref.extension_id, &zip_bytes)
164}
165
166/// Cache/handle-aware entry point that resolves an `ext://<id>#component` ref,
167/// acquiring the extension `.gtxpack` over the network when its declared source
168/// is `store://` (and, guarded, `oci://`). `file://`/bare sources behave exactly
169/// as [`resolve_ext_component`].
170///
171/// - `cache_dir` is the runtime cache dir (downloaded artifacts are cached under it).
172/// - `offline` disables network fetches and forces cache-only resolution.
173/// - `handle` is an optional current Tokio runtime handle; the store path uses
174///   blocking `reqwest` on a dedicated thread, so it does not require one.
175pub fn resolve_ext_component_with_dist(
176    pack_dir: &Path,
177    raw_ref: &str,
178    cache_dir: &Path,
179    offline: bool,
180    handle: Option<&Handle>,
181) -> Result<(Vec<u8>, String)> {
182    let (ext_ref, dep) = lookup_ext_dependency(pack_dir, raw_ref)?;
183    let zip_bytes = acquire_extension_bytes(&dep.source, cache_dir, offline, handle)
184        .with_context(|| format!("acquire source for extension '{}'", dep.id))?;
185    extract_and_verify_bytes(&ext_ref.extension_id, &zip_bytes)
186}
187
188/// Parse the `ext://` ref and locate the matching dependency in
189/// `pack.extensions.json`.
190fn lookup_ext_dependency(pack_dir: &Path, raw_ref: &str) -> Result<(ExtRef, ExtensionDependency)> {
191    let ext_ref = parse_ext_ref(raw_ref)?;
192    let extensions_path = pack_dir.join("pack.extensions.json");
193    let extensions = read_extensions_file(&extensions_path)
194        .with_context(|| format!("read pack.extensions.json from {}", pack_dir.display()))?;
195    let dep = find_extension_dep(&extensions, &ext_ref.extension_id)
196        .with_context(|| {
197            format!(
198                "ext:// component ref names extension '{}' not declared in pack.extensions.json",
199                ext_ref.extension_id
200            )
201        })?
202        .clone();
203    Ok((ext_ref, dep))
204}
205
206fn find_extension_dep<'a>(
207    file: &'a PackExtensionsFile,
208    id: &str,
209) -> Option<&'a ExtensionDependency> {
210    file.extensions.iter().find(|dep| dep.id == id)
211}
212
213/// Read a `file://` or bare-path extension source into ZIP bytes.
214///
215/// Network schemes are rejected here; callers needing them must use
216/// [`acquire_extension_bytes`].
217fn read_local_extension_source(source: &ExtensionDependencySource) -> Result<Vec<u8>> {
218    let raw = source.reference.as_str();
219    if let Some(path) = local_path_for_source(raw) {
220        return std::fs::read(&path)
221            .with_context(|| format!("read extension .gtxpack at {}", path.display()));
222    }
223    bail!(
224        "ext:// component resolver here only supports file:// or bare local extension sources, got '{raw}' (use the dist-aware resolver for store://)"
225    );
226}
227
228/// Map a `file://` or bare-path ref to a filesystem path; `None` for any scheme.
229fn local_path_for_source(raw: &str) -> Option<PathBuf> {
230    if let Some(path_str) = raw.strip_prefix("file://") {
231        return Some(PathBuf::from(path_str));
232    }
233    if !raw.contains("://") {
234        return Some(PathBuf::from(raw));
235    }
236    None
237}
238
239/// Acquire the extension `.gtxpack` bytes for any supported source scheme.
240///
241/// - `file://`/bare → filesystem read (unchanged from Phase 1).
242/// - `store://`     → store artifact endpoint GET, cached by archive sha256.
243/// - `oci://`       → guarded/deferred (no producer yet) → clear error.
244fn acquire_extension_bytes(
245    source: &ExtensionDependencySource,
246    cache_dir: &Path,
247    offline: bool,
248    _handle: Option<&Handle>,
249) -> Result<Vec<u8>> {
250    let raw = source.reference.as_str();
251    if local_path_for_source(raw).is_some() {
252        return read_local_extension_source(source);
253    }
254    if raw.starts_with("store://") {
255        let store_ref = parse_store_ref(raw)?;
256        return acquire_store_extension_bytes(&store_ref, cache_dir, offline);
257    }
258    if raw.starts_with("oci://") {
259        // No producer publishes extensions to OCI yet; the DistClient is
260        // wasm/pack media-type centric and would likely reject a `.gtxpack`.
261        // Bail with a clear, actionable message rather than a fragile path.
262        bail!(
263            "oci:// extension acquisition not yet supported (no producer); declare the extension with a store:// or file:// source instead (got '{raw}')"
264        );
265    }
266    bail!("unsupported extension source scheme for ext:// resolution: '{raw}'");
267}
268
269/// Acquire (and cache) the extension `.gtxpack` from the store artifact endpoint.
270fn acquire_store_extension_bytes(
271    store_ref: &StoreRef,
272    cache_dir: &Path,
273    offline: bool,
274) -> Result<Vec<u8>> {
275    if offline {
276        // Offline: we cannot resolve the artifact sha without a download, so we
277        // can only serve a previously cached artifact for this exact ref.
278        return read_cached_store_artifact(cache_dir, store_ref).ok_or_else(|| {
279            anyhow!(
280                "offline: no cached artifact for store extension '{}@{}' under the cache dir; run online once to populate the cache",
281                store_ref.name,
282                store_ref.version
283            )
284        });
285    }
286
287    let store_base = std::env::var(STORE_URL_ENV).map_err(|_| {
288        anyhow!(
289            "{STORE_URL_ENV} is not set; it must name the store base URL to acquire store:// extension '{}@{}'",
290            store_ref.name,
291            store_ref.version
292        )
293    })?;
294    download_store_artifact(&store_base, store_ref, cache_dir)
295}
296
297/// Download the extension `.gtxpack` from `store_base` for `store_ref`, verify
298/// the whole-archive `x-artifact-sha256` (when advertised), cache it under
299/// `cache_dir`, and return the bytes.
300///
301/// Separated from env resolution so it is directly testable against a local
302/// HTTP server without mutating the process environment.
303pub fn download_store_artifact(
304    store_base: &str,
305    store_ref: &StoreRef,
306    cache_dir: &Path,
307) -> Result<Vec<u8>> {
308    let url = store_artifact_url(store_base, &store_ref.name, &store_ref.version);
309
310    let (bytes, advertised_sha) = http_get_artifact(&url)?;
311    let actual_sha = hex::encode(Sha256::digest(&bytes));
312    if let Some(advertised) = advertised_sha.as_deref()
313        && !advertised.eq_ignore_ascii_case(&actual_sha)
314    {
315        bail!(
316            "store artifact integrity check failed for '{}@{}': x-artifact-sha256 advertises '{}' but body hashes to '{}'",
317            store_ref.name,
318            store_ref.version,
319            advertised,
320            actual_sha
321        );
322    }
323
324    // Cache keyed by archive sha256 (+ ref-keyed copy for offline reuse).
325    cache_store_artifact(cache_dir, store_ref, &actual_sha, &bytes)?;
326    Ok(bytes)
327}
328
329/// Directory under the runtime cache where store extension artifacts are kept.
330fn store_artifact_cache_dir(cache_dir: &Path) -> PathBuf {
331    cache_dir.join("ext-store")
332}
333
334/// Filesystem-safe key for a store ref (`name@version` with `/` and `@` escaped).
335fn store_ref_cache_key(store_ref: &StoreRef) -> String {
336    let sanitized =
337        format!("{}@{}", store_ref.name, store_ref.version).replace(['/', '\\', ':', '@'], "_");
338    format!("{sanitized}.gtxpack")
339}
340
341/// Write the artifact into the cache under both its archive-sha name and a
342/// ref-keyed name (so offline mode can find it by `name@version`).
343fn cache_store_artifact(
344    cache_dir: &Path,
345    store_ref: &StoreRef,
346    archive_sha: &str,
347    bytes: &[u8],
348) -> Result<()> {
349    let dir = store_artifact_cache_dir(cache_dir);
350    std::fs::create_dir_all(&dir)
351        .with_context(|| format!("create store artifact cache dir {}", dir.display()))?;
352    let sha_path = dir.join(format!("sha256-{archive_sha}.gtxpack"));
353    std::fs::write(&sha_path, bytes)
354        .with_context(|| format!("write store artifact cache {}", sha_path.display()))?;
355    let ref_path = dir.join(store_ref_cache_key(store_ref));
356    std::fs::write(&ref_path, bytes)
357        .with_context(|| format!("write store artifact cache {}", ref_path.display()))?;
358    Ok(())
359}
360
361/// Read a previously cached store artifact by ref key, if present.
362fn read_cached_store_artifact(cache_dir: &Path, store_ref: &StoreRef) -> Option<Vec<u8>> {
363    let path = store_artifact_cache_dir(cache_dir).join(store_ref_cache_key(store_ref));
364    std::fs::read(path).ok()
365}
366
367/// Blocking HTTP GET of the store artifact endpoint, returning the body bytes
368/// and the optional `x-artifact-sha256` header value.
369///
370/// Runs `reqwest::blocking` on a dedicated thread (mirroring `wizard_catalog`)
371/// so it is safe to call from within a Tokio runtime.
372fn http_get_artifact(url: &str) -> Result<(Vec<u8>, Option<String>)> {
373    let url = url.to_string();
374    std::thread::spawn(move || -> Result<(Vec<u8>, Option<String>)> {
375        let client = reqwest::blocking::Client::builder()
376            .connect_timeout(Duration::from_secs(5))
377            .timeout(Duration::from_secs(60))
378            .build()
379            .context("build HTTP client for store extension artifact")?;
380        let response = client
381            .get(&url)
382            .send()
383            .with_context(|| format!("request store extension artifact {url}"))?;
384        if response.status() != reqwest::StatusCode::OK {
385            bail!(
386                "store extension artifact {url} request failed with status {}",
387                response.status()
388            );
389        }
390        let advertised_sha = response
391            .headers()
392            .get("x-artifact-sha256")
393            .and_then(|value| value.to_str().ok())
394            .map(|value| value.trim().to_string());
395        let bytes = response
396            .bytes()
397            .with_context(|| format!("read store extension artifact response {url}"))?;
398        Ok((bytes.to_vec(), advertised_sha))
399    })
400    .join()
401    .map_err(|_| anyhow!("store artifact download thread panicked"))?
402}
403
404/// Read the `component.json` sidecar + the component wasm asset from `.gtxpack`
405/// ZIP `zip_bytes`, verify the digest, and return (wasm_bytes, verified_digest).
406pub fn extract_and_verify_bytes(extension_id: &str, zip_bytes: &[u8]) -> Result<(Vec<u8>, String)> {
407    let cursor = Cursor::new(zip_bytes);
408    let mut archive = zip::ZipArchive::new(cursor)
409        .with_context(|| format!("open extension .gtxpack ZIP for '{extension_id}'"))?;
410
411    // Read the component.json sidecar.
412    let sidecar: GtxpackComponentSidecar = {
413        let mut entry = archive.by_name("component.json").map_err(|_| {
414            anyhow!(
415                "extension '{extension_id}' does not embed a runtime component: 'component.json' not found in .gtxpack"
416            )
417        })?;
418        let mut buf = Vec::new();
419        entry
420            .read_to_end(&mut buf)
421            .with_context(|| format!("read component.json for '{extension_id}'"))?;
422        serde_json::from_slice(&buf)
423            .with_context(|| format!("parse component.json for '{extension_id}'"))?
424    };
425
426    // Validate the component entry.
427    let asset_path = sidecar.component.asset.as_str();
428    if asset_path.trim().is_empty() {
429        bail!(
430            "extension '{extension_id}' does not embed a runtime component: 'component.json' component.asset is empty"
431        );
432    }
433
434    // Read the wasm asset.
435    let wasm_bytes = {
436        let mut entry = archive.by_name(asset_path).map_err(|_| {
437            anyhow!(
438                "extension '{extension_id}' does not embed a runtime component: asset '{asset_path}' not found in .gtxpack"
439            )
440        })?;
441        let mut buf = Vec::new();
442        entry
443            .read_to_end(&mut buf)
444            .with_context(|| format!("read asset '{asset_path}' for '{extension_id}'"))?;
445        buf
446    };
447
448    // Compute actual digest.
449    let actual_digest = format!("sha256:{}", hex::encode(Sha256::digest(&wasm_bytes)));
450
451    // Verify against the component.json advertised digest.
452    let expected_digest = sidecar.component.digest.as_str();
453    if actual_digest != expected_digest {
454        bail!(
455            "embedded component digest mismatch for extension '{extension_id}': component.json advertises '{expected_digest}' but extracted wasm hashes to '{actual_digest}'"
456        );
457    }
458
459    Ok((wasm_bytes, actual_digest))
460}
461
462/// Read the `describe.json` sidecar from a `.gtxpack` ZIP.
463///
464/// Returns the raw `describe.json` bytes so callers can parse only the fields
465/// they need (e.g. via [`crate::setup_gen::extract_tool_secret_requirements`]).
466pub fn read_describe_from_gtxpack(extension_id: &str, zip_bytes: &[u8]) -> Result<Vec<u8>> {
467    let cursor = Cursor::new(zip_bytes);
468    let mut archive = zip::ZipArchive::new(cursor)
469        .with_context(|| format!("open extension .gtxpack ZIP for '{extension_id}'"))?;
470    let mut file = archive
471        .by_name("describe.json")
472        .with_context(|| format!("extension '{extension_id}' .gtxpack has no describe.json"))?;
473    let mut body = Vec::new();
474    file.read_to_end(&mut body)
475        .with_context(|| format!("read describe.json from '{extension_id}' .gtxpack"))?;
476    Ok(body)
477}
478
479/// For each tool extension used by the agents, acquire its `.gtxpack`, read
480/// `describe.json`, and extract the secret requirements of the used tools.
481///
482/// Returns a map keyed by extension id. Errors (and propagates via `?`) when a
483/// declared tool extension is not found in `pack.extensions.json` or cannot be
484/// acquired — no silent skips.
485///
486/// `pack_dir` must contain `pack.extensions.json`. `cache_dir` and `offline`
487/// are threaded through to [`acquire_extension_bytes`] for `store://` sources.
488pub fn resolve_agent_tool_requirements(
489    pack_dir: &Path,
490    agents: &std::collections::BTreeMap<String, serde_json::Value>,
491    cache_dir: &Path,
492    offline: bool,
493) -> Result<std::collections::BTreeMap<String, Vec<crate::setup_gen::ToolSecretReq>>> {
494    use std::collections::{BTreeMap, BTreeSet};
495
496    // Collect extension_id -> set(tool_name) actually used across all agents.
497    let mut used: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
498    for (agent_name, agent) in agents {
499        let Some(tools) = agent.get("tools").and_then(|t| t.as_array()) else {
500            continue;
501        };
502        for tool in tools {
503            let (Some(ext_id), Some(tool_name)) = (
504                tool.get("extension_id").and_then(|e| e.as_str()),
505                tool.get("tool_name").and_then(|n| n.as_str()),
506            ) else {
507                tracing::warn!(
508                    agent = %agent_name,
509                    "skipping malformed agent tool entry: missing extension_id or tool_name"
510                );
511                continue;
512            };
513            used.entry(ext_id.to_string())
514                .or_default()
515                .insert(tool_name.to_string());
516        }
517    }
518
519    let mut out = BTreeMap::new();
520    for (ext_id, tool_names) in &used {
521        // Reuse lookup_ext_dependency — it requires the #component fragment for
522        // the parse_ext_ref validator even though we only need describe.json.
523        let raw_ref = format!("ext://{ext_id}#component");
524        let (_ext_ref, dep) = lookup_ext_dependency(pack_dir, &raw_ref).with_context(|| {
525            format!("resolve tool extension '{ext_id}' for credential form generation")
526        })?;
527        let zip_bytes = acquire_extension_bytes(&dep.source, cache_dir, offline, None)
528            .with_context(|| format!("acquire .gtxpack for tool extension '{ext_id}'"))?;
529        let describe_bytes = read_describe_from_gtxpack(ext_id, &zip_bytes)?;
530        let names: Vec<String> = tool_names.iter().cloned().collect();
531        let secret_requirements =
532            crate::setup_gen::extract_tool_secret_requirements(&describe_bytes, &names)?;
533        out.insert(ext_id.clone(), secret_requirements);
534    }
535    Ok(out)
536}
537
538#[cfg(test)]
539mod describe_tests {
540    use super::*;
541    use std::io::Write;
542
543    fn gtxpack_with_describe(describe: &str) -> Vec<u8> {
544        let mut buf = Vec::new();
545        {
546            let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
547            zip.start_file("describe.json", zip::write::FileOptions::<()>::default())
548                .unwrap();
549            zip.write_all(describe.as_bytes()).unwrap();
550            zip.finish().unwrap();
551        }
552        buf
553    }
554
555    #[test]
556    fn reads_describe_json_entry_from_gtxpack() {
557        let bytes = gtxpack_with_describe(r#"{"contributions":{"tools":[]}}"#);
558        let body = read_describe_from_gtxpack("greentic.tavily", &bytes).unwrap();
559        assert!(String::from_utf8_lossy(&body).contains("contributions"));
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    #[test]
568    fn parse_ext_ref_happy() {
569        let r = parse_ext_ref("ext://greentic.http#component").expect("valid ref");
570        assert_eq!(r.extension_id, "greentic.http");
571    }
572
573    #[test]
574    fn parse_ext_ref_wrong_scheme() {
575        let err = parse_ext_ref("oci://foo#component").expect_err("wrong scheme");
576        assert!(err.to_string().contains("ext://"));
577    }
578
579    #[test]
580    fn parse_ext_ref_no_fragment() {
581        let err = parse_ext_ref("ext://greentic.http").expect_err("no fragment");
582        assert!(err.to_string().contains("#component"));
583    }
584
585    #[test]
586    fn parse_ext_ref_wrong_fragment() {
587        let err = parse_ext_ref("ext://greentic.http#other").expect_err("wrong fragment");
588        assert!(err.to_string().contains("#component"));
589    }
590
591    #[test]
592    fn parse_ext_ref_empty_id() {
593        let err = parse_ext_ref("ext://#component").expect_err("empty id");
594        assert!(err.to_string().contains("must not be empty"));
595    }
596}