Skip to main content

bamboo_agent/
plugin_cli.rs

1//! The `bamboo plugin install|list|remove|update` CLI — a thin HTTP client
2//! over a running `bamboo serve` instance's `/api/v1/plugins` routes.
3//!
4//! Mirrors the `bamboo mcp ...` verb pattern in [`crate::admin_cli`]: this
5//! module only builds request bodies, resolves the base URL (via the shared
6//! [`ConnArgs`]) and pretty-prints responses. The server (built in parallel
7//! against the same frozen contract) is the single source of truth for
8//! whether an install/update/remove actually succeeds.
9//!
10//! Wire contract (frozen — see `PLUGIN_PLAN.md` §"2. CLI agent" / §"3. HTTP
11//! agent"):
12//! - `GET /api/v1/plugins` -> `{ "plugins": [ { id, name?, version, source,
13//!   status, registered: { mcp_server_ids, preset_ids, skill_dirs,
14//!   workflow_filenames } } ] }`
15//! - `POST /api/v1/plugins/install` -> body `{ "source": <SourceSpec> }`
16//!   (`InstallDisposition::FailIfInstalled`); `SourceSpec` is one of
17//!   `{"type":"local_dir","path":"..."}` / `{"type":"local_archive","path":"..."}`
18//!   / `{"type":"url","url":"...","sha256":"..."?,"allow_unverified":bool?,
19//!   "allow_untrusted_host":bool?,"allow_unsigned":bool?,"insecure":bool?}` —
20//!   the same tagged shape as `bamboo_plugin::registry::PluginSource`'s
21//!   `#[serde(tag = "type")]` wire form, reproduced here by hand (this crate
22//!   does not depend on `bamboo-plugin`, to stay decoupled from the parallel
23//!   installer-core branch).
24//! - `POST /api/v1/plugins/{id}/update` -> same body shape (`Upgrade`).
25//! - `DELETE /api/v1/plugins/{id}` -> uninstall.
26//! - Errors: 409 (Conflict / AlreadyInstalled), 422 (UnsupportedPlatform), 404
27//!   (NotFound), 403 (`url` source: untrusted host / unsigned-or-untrusted
28//!   signature), 400 (bad manifest/artifact/bundle checksum, or a `url`
29//!   install missing both `sha256` and `allow_unverified`); the body uses
30//!   the canonical `{"error":{"message":"...","type":"api_error"}}`
31//!   envelope (while the CLI still accepts older flat-string responses).
32//!
33//! # URL installs: three trust layers, secure by default
34//!
35//! A `url` source is checked against three independent, stacked layers (see
36//! `bamboo-server`'s `plugin_source.rs` module docs for the full precedence):
37//!
38//! 1. **Host allowlist** — the URL's host+path must match an operator-trusted
39//!    prefix (`plugin_trust.trusted_hosts` in `config.json`; the default
40//!    trusts `github.com/bigduu/`) unless `--allow-untrusted-host` is passed.
41//! 2. **Signature** — the bundle's `<url>.sig` must verify against an
42//!    operator-trusted ed25519 key (`plugin_trust.trusted_keys`; the default
43//!    trusts nova's official signing key) unless `--allow-unsigned` is
44//!    passed.
45//! 3. **Checksum** — `sha256` pins the downloaded BUNDLE (the `plugin.json`,
46//!    or the archive containing it) — NOT merely the per-platform binary
47//!    artifact declared inside the manifest (that is separately, and always,
48//!    sha256-verified against the manifest's own declaration). A `url`
49//!    install with neither `sha256` nor `allow_unverified: true` is refused
50//!    UNLESS layer 2 already verified a signature (a verified signature is a
51//!    stronger integrity+authenticity guarantee than a pasted checksum, so it
52//!    satisfies this layer on its own).
53//!
54//! Net effect: installing the OFFICIAL nova plugin from its GitHub release
55//! needs NO flags at all once nova's release CI signs the bundle (trusted
56//! host + verified signature). An install from an untrusted host or an
57//! unsigned/untrusted-signature bundle needs the matching explicit opt-out
58//! flag(s) — `bamboo plugin install <url>` alone no longer just downloads
59//! and trusts any tar.gz from any host.
60//!
61//! # `--insecure`: skip ALL three layers at once
62//!
63//! `--insecure` (`install`/`update`) is a convenience AGGREGATE over the
64//! three flags above — equivalent to passing `--allow-untrusted-host
65//! --allow-unsigned --allow-unverified` together, for the one install it's
66//! given on. It only turns default-required checks OFF: a `--sha256` passed
67//! alongside `--insecure` is still verified (a mismatch still refuses the
68//! install) — the flag never downgrades a check the caller explicitly opted
69//! into. There's also a persistent, config-level form for a private/dev
70//! bamboo instance that never wants to pass flags at all:
71//! `bamboo config set plugin_trust.enforcement off` makes EVERY `url`
72//! install/update behave this way with no per-install flag needed
73//! (`plugin_trust.enforcement` defaults to `"strict"`, so this is always an
74//! explicit opt-in relaxation). Use either only for sources you fully trust
75//! (dev/self-hosted/custom setups) — the server logs a prominent warning for
76//! every insecure install (plus its own startup warning when
77//! `plugin_trust.enforcement` is `off`) and records the aggregate in
78//! provenance, visible via `bamboo plugin list --json`.
79
80use std::path::Path;
81use std::time::Duration;
82
83use colored::Colorize;
84
85use crate::admin_cli::{
86    confirm, guard_id_segment, server_error_message, truncate, unreachable, ConnArgs,
87};
88
89/// Plain reads (`list`) get the ordinary admin-CLI budget.
90const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
91
92/// Install/update can copy a local archive, unpack a `.tar.gz`/`.zip`, or
93/// download over the network — give it a generous budget vs. the plain reads,
94/// matching the MCP mutate verbs' posture (stdio child spawn, etc.).
95const PLUGIN_MUTATE_TIMEOUT: Duration = Duration::from_secs(120);
96
97/// Auto-detect the `SourceSpec` JSON for a `<path-or-url>` CLI argument:
98/// - an existing directory -> `{"type":"local_dir","path":<absolute path>}`
99/// - an existing file ending `.tar.gz`/`.tgz`/`.zip` ->
100///   `{"type":"local_archive","path":<absolute path>}`
101/// - something starting `http://`/`https://` -> `{"type":"url","url":<as-is>}`
102///   (+ `"sha256"` when `--sha256` was given, `"allow_unverified":true` when
103///   `--allow-unverified` was given, `"allow_untrusted_host":true` when
104///   `--allow-untrusted-host` was given, `"allow_unsigned":true` when
105///   `--allow-unsigned` was given, and — when `--insecure` was given —
106///   `"insecure":true` PLUS `"allow_untrusted_host"`/`"allow_unsigned"`/
107///   `"allow_unverified"` all forced to `true` too, so the built request is
108///   self-describing (see the `--insecure` section below))
109///
110/// Local paths are canonicalized to absolute so the source resolves correctly
111/// even if `bamboo serve` runs with a different working directory than the
112/// CLI invocation (e.g. a long-running sidecar). All five flags
113/// (`--sha256`/`--allow-unverified`/`--allow-untrusted-host`/
114/// `--allow-unsigned`/`--insecure`) are rejected for local sources — they
115/// only apply to a network download; a local file is already on the user's
116/// own disk by their own choice, nothing to verify/authorize.
117///
118/// # `--insecure`: the convenience aggregate
119///
120/// `--insecure` is shorthand for `--allow-untrusted-host --allow-unsigned
121/// --allow-unverified` together — skip ALL THREE trust layers (host
122/// allowlist, signature, checksum-required-by-default) for this one install.
123/// Precedence: it only turns OFF checks the caller didn't otherwise ask for —
124/// a `--sha256` passed alongside `--insecure` is still honored (the server
125/// verifies it regardless; a wrong hash still refuses the install). Use only
126/// for sources you fully trust (dev/self-hosted/custom setups); the server
127/// logs a prominent warning for every insecure install and records it in
128/// provenance (`bamboo plugin list`).
129pub(crate) fn detect_source(
130    spec: &str,
131    sha256: Option<&str>,
132    allow_unverified: bool,
133    allow_untrusted_host: bool,
134    allow_unsigned: bool,
135    insecure: bool,
136) -> anyhow::Result<serde_json::Value> {
137    if spec.starts_with("http://") || spec.starts_with("https://") {
138        let mut v = serde_json::json!({ "type": "url", "url": spec });
139        if let Some(sha) = sha256 {
140            v["sha256"] = serde_json::Value::String(sha.to_string());
141        }
142        if allow_unverified {
143            v["allow_unverified"] = serde_json::Value::Bool(true);
144        }
145        if allow_untrusted_host {
146            v["allow_untrusted_host"] = serde_json::Value::Bool(true);
147        }
148        if allow_unsigned {
149            v["allow_unsigned"] = serde_json::Value::Bool(true);
150        }
151        if insecure {
152            // The aggregate: mark the request as insecure AND set the three
153            // individual flags it implies, so the built request is
154            // self-describing on the wire (and would behave identically even
155            // against an older server that only understood the three
156            // per-layer flags and not `insecure` itself).
157            v["insecure"] = serde_json::Value::Bool(true);
158            v["allow_untrusted_host"] = serde_json::Value::Bool(true);
159            v["allow_unsigned"] = serde_json::Value::Bool(true);
160            v["allow_unverified"] = serde_json::Value::Bool(true);
161        }
162        return Ok(v);
163    }
164
165    let path = Path::new(spec);
166    let metadata = std::fs::metadata(path)
167        .map_err(|e| anyhow::anyhow!("cannot read '{spec}': {e} (expected a directory, a .tar.gz/.tgz/.zip archive, or an http(s):// URL)"))?;
168    let abs = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
169
170    if metadata.is_dir() {
171        if sha256.is_some() {
172            anyhow::bail!("--sha256 only applies to a URL source, not a local directory");
173        }
174        if allow_unverified {
175            anyhow::bail!("--allow-unverified only applies to a URL source, not a local directory");
176        }
177        if allow_untrusted_host {
178            anyhow::bail!(
179                "--allow-untrusted-host only applies to a URL source, not a local directory"
180            );
181        }
182        if allow_unsigned {
183            anyhow::bail!("--allow-unsigned only applies to a URL source, not a local directory");
184        }
185        if insecure {
186            anyhow::bail!("--insecure only applies to a URL source, not a local directory");
187        }
188        return Ok(serde_json::json!({ "type": "local_dir", "path": abs }));
189    }
190
191    let lower = spec.to_ascii_lowercase();
192    if metadata.is_file()
193        && (lower.ends_with(".tar.gz") || lower.ends_with(".tgz") || lower.ends_with(".zip"))
194    {
195        if sha256.is_some() {
196            anyhow::bail!("--sha256 only applies to a URL source, not a local archive");
197        }
198        if allow_unverified {
199            anyhow::bail!("--allow-unverified only applies to a URL source, not a local archive");
200        }
201        if allow_untrusted_host {
202            anyhow::bail!(
203                "--allow-untrusted-host only applies to a URL source, not a local archive"
204            );
205        }
206        if allow_unsigned {
207            anyhow::bail!("--allow-unsigned only applies to a URL source, not a local archive");
208        }
209        if insecure {
210            anyhow::bail!("--insecure only applies to a URL source, not a local archive");
211        }
212        return Ok(serde_json::json!({ "type": "local_archive", "path": abs }));
213    }
214
215    anyhow::bail!(
216        "'{spec}' is neither a directory, a recognized archive (.tar.gz/.tgz/.zip), nor an http(s):// URL"
217    )
218}
219
220/// `bamboo plugin install <path-or-url> [--sha256 <hex>] [--allow-unverified]
221/// [--allow-untrusted-host] [--allow-unsigned] [--insecure]` —
222/// `POST /api/v1/plugins/install`.
223/// On a 409 (already installed) prints a pointer to `bamboo plugin update`
224/// and returns an error (non-zero exit). A URL source with neither `--sha256`
225/// nor `--allow-unverified` gets a 400 from the server (secure by default —
226/// see the module docs); a URL from a host outside `plugin_trust.trusted_hosts`
227/// or an unsigned/untrusted-signature bundle gets a 403 (unless the matching
228/// opt-out flag, or `--insecure`, was passed). Every one of those error bodies
229/// is already the actionable "pass --X" guidance, surfaced as-is through the
230/// branches below.
231pub async fn install(
232    conn: ConnArgs,
233    source_spec: &str,
234    sha256: Option<&str>,
235    allow_unverified: bool,
236    allow_untrusted_host: bool,
237    allow_unsigned: bool,
238    insecure: bool,
239) -> anyhow::Result<()> {
240    let source = detect_source(
241        source_spec,
242        sha256,
243        allow_unverified,
244        allow_untrusted_host,
245        allow_unsigned,
246        insecure,
247    )?;
248    let base = conn.api_base();
249    let url = format!("{base}/plugins/install");
250    let resp = reqwest::Client::new()
251        .post(&url)
252        .timeout(PLUGIN_MUTATE_TIMEOUT)
253        .json(&serde_json::json!({ "source": source }))
254        .send()
255        .await
256        .map_err(|e| unreachable(&base, e))?;
257    let status = resp.status();
258    let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
259    if status.is_success() {
260        let id_suffix = body
261            .get("id")
262            .and_then(|s| s.as_str())
263            .or_else(|| {
264                body.get("plugin")
265                    .and_then(|p| p.get("id"))
266                    .and_then(|s| s.as_str())
267            })
268            .map(|id| format!(" '{id}'"))
269            .unwrap_or_default();
270        println!(
271            "{} plugin{id_suffix} installed from '{source_spec}'",
272            "✓".green()
273        );
274        Ok(())
275    } else if status.as_u16() == 409 {
276        anyhow::bail!(
277            "plugin already installed {} — use `bamboo plugin update <id> <path-or-url>` to reinstall/upgrade it",
278            server_error_message(&body)
279        );
280    } else if status.as_u16() == 422 {
281        anyhow::bail!("unsupported platform {}", server_error_message(&body));
282    } else if status.as_u16() == 403 {
283        anyhow::bail!(
284            "install refused (source trust) {} — for an untrusted host, add it to \
285             `plugin_trust.trusted_hosts` in config.json or pass --allow-untrusted-host; for an \
286             unsigned/untrusted-signature bundle, pass --allow-unsigned; or skip all three trust \
287             checks at once with --insecure (only for sources you fully trust)",
288            server_error_message(&body)
289        );
290    } else {
291        anyhow::bail!(
292            "install failed: HTTP {status} {}",
293            server_error_message(&body)
294        );
295    }
296}
297
298/// `bamboo plugin list [--json]` — `GET /api/v1/plugins`.
299pub async fn list(conn: ConnArgs, json: bool) -> anyhow::Result<()> {
300    let base = conn.api_base();
301    let url = format!("{base}/plugins");
302    let resp = reqwest::Client::new()
303        .get(&url)
304        .timeout(REQUEST_TIMEOUT)
305        .send()
306        .await
307        .map_err(|e| unreachable(&base, e))?;
308    if !resp.status().is_success() {
309        anyhow::bail!("GET {url} -> HTTP {}", resp.status());
310    }
311    let v: serde_json::Value = resp.json().await?;
312    if json {
313        println!("{}", serde_json::to_string_pretty(&v)?);
314        return Ok(());
315    }
316
317    let plugins = v.get("plugins").and_then(|p| p.as_array());
318    let plugins = match plugins {
319        Some(p) if !p.is_empty() => p,
320        _ => {
321            println!("(no plugins installed)");
322            return Ok(());
323        }
324    };
325
326    println!(
327        "{:<20} {:<10} {:<12} {:>4} {:>4} {:>4} {:>4}  SOURCE",
328        "ID", "VERSION", "STATUS", "MCP", "SKL", "PST", "WFL"
329    );
330    for p in plugins {
331        let id = p.get("id").and_then(|x| x.as_str()).unwrap_or("?");
332        let version = p.get("version").and_then(|x| x.as_str()).unwrap_or("-");
333        let status = p.get("status").and_then(|x| x.as_str()).unwrap_or("?");
334        let registered = p.get("registered");
335        let count = |key: &str| {
336            registered
337                .and_then(|r| r.get(key))
338                .and_then(|a| a.as_array())
339                .map(|a| a.len())
340                .unwrap_or(0)
341        };
342        println!(
343            "{:<20} {:<10} {:<12} {:>4} {:>4} {:>4} {:>4}  {}",
344            truncate(id, 20),
345            truncate(version, 10),
346            truncate(status, 12),
347            count("mcp_server_ids"),
348            count("skill_dirs"),
349            count("preset_ids"),
350            count("workflow_filenames"),
351            truncate(&format_source(p.get("source")), 50)
352        );
353    }
354    println!("\n{} plugin(s).", plugins.len());
355    Ok(())
356}
357
358/// One-line rendering of a `PluginSource` JSON value for the list table.
359fn format_source(source: Option<&serde_json::Value>) -> String {
360    let Some(source) = source else {
361        return "-".to_string();
362    };
363    match source.get("type").and_then(|t| t.as_str()) {
364        Some("local_dir") => format!(
365            "local_dir:{}",
366            source.get("path").and_then(|p| p.as_str()).unwrap_or("?")
367        ),
368        Some("local_archive") => format!(
369            "local_archive:{}",
370            source.get("path").and_then(|p| p.as_str()).unwrap_or("?")
371        ),
372        Some("url") => format!(
373            "url:{}",
374            source.get("url").and_then(|u| u.as_str()).unwrap_or("?")
375        ),
376        _ => source.to_string(),
377    }
378}
379
380/// `bamboo plugin remove <id> [--yes]` — `DELETE /api/v1/plugins/{id}`.
381/// Destructive (stops/removes its registered MCP servers, prompt presets and
382/// workflow files, then deletes the plugin directory), so it confirms like
383/// `mcp remove` / `session delete` unless `--yes`.
384pub async fn remove(conn: ConnArgs, id: &str, yes: bool) -> anyhow::Result<()> {
385    guard_id_segment("plugin id", id)?;
386    if !yes
387        && !confirm(&format!(
388            "Remove plugin '{id}'? This uninstalls it and deletes its registered capabilities."
389        ))?
390    {
391        println!("aborted (nothing removed).");
392        return Ok(());
393    }
394    let base = conn.api_base();
395    let url = format!("{base}/plugins/{id}");
396    let resp = reqwest::Client::new()
397        .delete(&url)
398        .timeout(PLUGIN_MUTATE_TIMEOUT)
399        .send()
400        .await
401        .map_err(|e| unreachable(&base, e))?;
402    let status = resp.status();
403    let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
404    if status.is_success() {
405        println!("{} plugin '{id}' removed", "✓".green());
406        Ok(())
407    } else if status.as_u16() == 404 {
408        anyhow::bail!("plugin '{id}' not found (check `bamboo plugin list`)");
409    } else {
410        anyhow::bail!(
411            "remove failed: HTTP {status} {}",
412            server_error_message(&body)
413        );
414    }
415}
416
417/// `bamboo plugin update <id> <path-or-url> [--sha256] [--allow-unverified]
418/// [--allow-untrusted-host] [--allow-unsigned] [--insecure]` —
419/// `POST /api/v1/plugins/{id}/update` (`InstallDisposition::Upgrade`). Same
420/// three-layer source-trust policy (plus the `--insecure` aggregate) as
421/// `install` (see the module docs).
422#[allow(clippy::too_many_arguments)]
423pub async fn update(
424    conn: ConnArgs,
425    id: &str,
426    source_spec: &str,
427    sha256: Option<&str>,
428    allow_unverified: bool,
429    allow_untrusted_host: bool,
430    allow_unsigned: bool,
431    insecure: bool,
432) -> anyhow::Result<()> {
433    guard_id_segment("plugin id", id)?;
434    let source = detect_source(
435        source_spec,
436        sha256,
437        allow_unverified,
438        allow_untrusted_host,
439        allow_unsigned,
440        insecure,
441    )?;
442    let base = conn.api_base();
443    let url = format!("{base}/plugins/{id}/update");
444    let resp = reqwest::Client::new()
445        .post(&url)
446        .timeout(PLUGIN_MUTATE_TIMEOUT)
447        .json(&serde_json::json!({ "source": source }))
448        .send()
449        .await
450        .map_err(|e| unreachable(&base, e))?;
451    let status = resp.status();
452    let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
453    if status.is_success() {
454        println!("{} plugin '{id}' updated from '{source_spec}'", "✓".green());
455        Ok(())
456    } else if status.as_u16() == 404 {
457        anyhow::bail!("plugin '{id}' not found (check `bamboo plugin list`)");
458    } else if status.as_u16() == 422 {
459        anyhow::bail!("unsupported platform {}", server_error_message(&body));
460    } else if status.as_u16() == 403 {
461        anyhow::bail!(
462            "update refused (source trust) {} — for an untrusted host, add it to \
463             `plugin_trust.trusted_hosts` in config.json or pass --allow-untrusted-host; for an \
464             unsigned/untrusted-signature bundle, pass --allow-unsigned; or skip all three trust \
465             checks at once with --insecure (only for sources you fully trust)",
466            server_error_message(&body)
467        );
468    } else {
469        anyhow::bail!(
470            "update failed: HTTP {status} {}",
471            server_error_message(&body)
472        );
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn detect_source_recognizes_http_and_https_urls() {
482        let v = detect_source(
483            "https://example.com/plugin.tar.gz",
484            None,
485            false,
486            false,
487            false,
488            false,
489        )
490        .unwrap();
491        assert_eq!(v["type"], "url");
492        assert_eq!(v["url"], "https://example.com/plugin.tar.gz");
493        assert!(v.get("sha256").is_none());
494        assert!(v.get("allow_unverified").is_none());
495        assert!(v.get("allow_untrusted_host").is_none());
496        assert!(v.get("allow_unsigned").is_none());
497
498        let v = detect_source(
499            "http://example.com/plugin.tar.gz",
500            Some("deadbeef"),
501            false,
502            false,
503            false,
504            false,
505        )
506        .unwrap();
507        assert_eq!(v["type"], "url");
508        assert_eq!(v["sha256"], "deadbeef");
509        assert!(v.get("allow_unverified").is_none());
510    }
511
512    #[test]
513    fn detect_source_url_carries_allow_unverified_when_set() {
514        let v = detect_source(
515            "https://example.com/plugin.tar.gz",
516            None,
517            true,
518            false,
519            false,
520            false,
521        )
522        .unwrap();
523        assert_eq!(v["type"], "url");
524        assert!(v.get("sha256").is_none());
525        assert_eq!(v["allow_unverified"], true);
526    }
527
528    #[test]
529    fn detect_source_url_carries_both_sha256_and_allow_unverified() {
530        // Both flags can be set together — the server treats `sha256` as
531        // authoritative when present (verify), so this isn't a conflicting
532        // request, just a redundant one.
533        let v = detect_source(
534            "https://example.com/plugin.tar.gz",
535            Some("deadbeef"),
536            true,
537            false,
538            false,
539            false,
540        )
541        .unwrap();
542        assert_eq!(v["sha256"], "deadbeef");
543        assert_eq!(v["allow_unverified"], true);
544    }
545
546    #[test]
547    fn detect_source_url_carries_allow_untrusted_host_when_set() {
548        let v = detect_source(
549            "https://evil.example.com/plugin.tar.gz",
550            None,
551            true,
552            true,
553            false,
554            false,
555        )
556        .unwrap();
557        assert_eq!(v["type"], "url");
558        assert_eq!(v["allow_untrusted_host"], true);
559        assert!(v.get("allow_unsigned").is_none());
560    }
561
562    #[test]
563    fn detect_source_url_carries_allow_unsigned_when_set() {
564        let v = detect_source(
565            "https://example.com/plugin.tar.gz",
566            None,
567            true,
568            false,
569            true,
570            false,
571        )
572        .unwrap();
573        assert_eq!(v["type"], "url");
574        assert!(v.get("allow_untrusted_host").is_none());
575        assert_eq!(v["allow_unsigned"], true);
576    }
577
578    #[test]
579    fn detect_source_url_carries_all_four_flags_together() {
580        let v = detect_source(
581            "https://example.com/plugin.tar.gz",
582            Some("deadbeef"),
583            true,
584            true,
585            true,
586            false,
587        )
588        .unwrap();
589        assert_eq!(v["sha256"], "deadbeef");
590        assert_eq!(v["allow_unverified"], true);
591        assert_eq!(v["allow_untrusted_host"], true);
592        assert_eq!(v["allow_unsigned"], true);
593    }
594
595    #[test]
596    fn detect_source_recognizes_local_dir() {
597        let dir = tempfile::tempdir().unwrap();
598        let v = detect_source(
599            dir.path().to_str().unwrap(),
600            None,
601            false,
602            false,
603            false,
604            false,
605        )
606        .unwrap();
607        assert_eq!(v["type"], "local_dir");
608        assert_eq!(
609            v["path"].as_str().unwrap(),
610            dir.path().canonicalize().unwrap().to_str().unwrap()
611        );
612    }
613
614    #[test]
615    fn detect_source_rejects_sha256_for_local_dir() {
616        let dir = tempfile::tempdir().unwrap();
617        let err = detect_source(
618            dir.path().to_str().unwrap(),
619            Some("deadbeef"),
620            false,
621            false,
622            false,
623            false,
624        )
625        .unwrap_err();
626        assert!(err.to_string().contains("--sha256"));
627    }
628
629    #[test]
630    fn detect_source_rejects_allow_unverified_for_local_dir() {
631        let dir = tempfile::tempdir().unwrap();
632        let err = detect_source(
633            dir.path().to_str().unwrap(),
634            None,
635            true,
636            false,
637            false,
638            false,
639        )
640        .unwrap_err();
641        assert!(err.to_string().contains("--allow-unverified"));
642    }
643
644    #[test]
645    fn detect_source_rejects_allow_untrusted_host_for_local_dir() {
646        let dir = tempfile::tempdir().unwrap();
647        let err = detect_source(
648            dir.path().to_str().unwrap(),
649            None,
650            false,
651            true,
652            false,
653            false,
654        )
655        .unwrap_err();
656        assert!(err.to_string().contains("--allow-untrusted-host"));
657    }
658
659    #[test]
660    fn detect_source_rejects_allow_unsigned_for_local_dir() {
661        let dir = tempfile::tempdir().unwrap();
662        let err = detect_source(
663            dir.path().to_str().unwrap(),
664            None,
665            false,
666            false,
667            true,
668            false,
669        )
670        .unwrap_err();
671        assert!(err.to_string().contains("--allow-unsigned"));
672    }
673
674    #[test]
675    fn detect_source_recognizes_archives_by_extension() {
676        let dir = tempfile::tempdir().unwrap();
677        for name in ["plugin.tar.gz", "plugin.tgz", "plugin.zip"] {
678            let path = dir.path().join(name);
679            std::fs::write(&path, b"fake archive bytes").unwrap();
680            let v =
681                detect_source(path.to_str().unwrap(), None, false, false, false, false).unwrap();
682            assert_eq!(v["type"], "local_archive", "{name}");
683        }
684    }
685
686    #[test]
687    fn detect_source_rejects_sha256_for_local_archive() {
688        let dir = tempfile::tempdir().unwrap();
689        let path = dir.path().join("plugin.tar.gz");
690        std::fs::write(&path, b"fake archive bytes").unwrap();
691        let err = detect_source(
692            path.to_str().unwrap(),
693            Some("deadbeef"),
694            false,
695            false,
696            false,
697            false,
698        )
699        .unwrap_err();
700        assert!(err.to_string().contains("--sha256"));
701    }
702
703    #[test]
704    fn detect_source_rejects_allow_unverified_for_local_archive() {
705        let dir = tempfile::tempdir().unwrap();
706        let path = dir.path().join("plugin.tar.gz");
707        std::fs::write(&path, b"fake archive bytes").unwrap();
708        let err =
709            detect_source(path.to_str().unwrap(), None, true, false, false, false).unwrap_err();
710        assert!(err.to_string().contains("--allow-unverified"));
711    }
712
713    #[test]
714    fn detect_source_rejects_allow_untrusted_host_for_local_archive() {
715        let dir = tempfile::tempdir().unwrap();
716        let path = dir.path().join("plugin.tar.gz");
717        std::fs::write(&path, b"fake archive bytes").unwrap();
718        let err =
719            detect_source(path.to_str().unwrap(), None, false, true, false, false).unwrap_err();
720        assert!(err.to_string().contains("--allow-untrusted-host"));
721    }
722
723    #[test]
724    fn detect_source_rejects_allow_unsigned_for_local_archive() {
725        let dir = tempfile::tempdir().unwrap();
726        let path = dir.path().join("plugin.tar.gz");
727        std::fs::write(&path, b"fake archive bytes").unwrap();
728        let err =
729            detect_source(path.to_str().unwrap(), None, false, false, true, false).unwrap_err();
730        assert!(err.to_string().contains("--allow-unsigned"));
731    }
732
733    #[test]
734    fn detect_source_rejects_unrecognized_file_extension() {
735        let dir = tempfile::tempdir().unwrap();
736        let path = dir.path().join("plugin.txt");
737        std::fs::write(&path, b"not an archive").unwrap();
738        let err =
739            detect_source(path.to_str().unwrap(), None, false, false, false, false).unwrap_err();
740        assert!(err.to_string().contains("neither a directory"));
741    }
742
743    #[test]
744    fn detect_source_rejects_missing_path() {
745        let err = detect_source(
746            "/no/such/path/should/exist/anywhere",
747            None,
748            false,
749            false,
750            false,
751            false,
752        )
753        .unwrap_err();
754        assert!(err.to_string().contains("cannot read"));
755    }
756
757    // ---------------------------------------------------------------------
758    // `--insecure`: the convenience aggregate over the three per-layer flags.
759    // ---------------------------------------------------------------------
760
761    #[test]
762    fn detect_source_insecure_implies_all_three_allow_flags() {
763        // `insecure: true` with every individual `allow_*` flag left `false`
764        // must still produce a request with all three set — that's the
765        // whole point of the aggregate.
766        let v = detect_source(
767            "https://example.com/my-plugin.tar.gz",
768            None,
769            false,
770            false,
771            false,
772            true,
773        )
774        .unwrap();
775        assert_eq!(v["type"], "url");
776        assert_eq!(v["insecure"], true);
777        assert_eq!(v["allow_untrusted_host"], true);
778        assert_eq!(v["allow_unsigned"], true);
779        assert_eq!(v["allow_unverified"], true);
780        assert!(v.get("sha256").is_none());
781    }
782
783    #[test]
784    fn detect_source_insecure_with_explicit_sha256_keeps_the_checksum() {
785        // Precedence: `--insecure` only turns default-required checks OFF —
786        // a caller-supplied `--sha256` is a check they opted INTO, so it must
787        // still be carried through to the request (the server verifies it
788        // regardless of `insecure`; see `plugin_source.rs`).
789        let v = detect_source(
790            "https://example.com/my-plugin.tar.gz",
791            Some("deadbeef"),
792            false,
793            false,
794            false,
795            true,
796        )
797        .unwrap();
798        assert_eq!(v["insecure"], true);
799        assert_eq!(v["sha256"], "deadbeef");
800        assert_eq!(v["allow_untrusted_host"], true);
801        assert_eq!(v["allow_unsigned"], true);
802        assert_eq!(v["allow_unverified"], true);
803    }
804
805    #[test]
806    fn detect_source_rejects_insecure_for_local_dir() {
807        let dir = tempfile::tempdir().unwrap();
808        let err = detect_source(
809            dir.path().to_str().unwrap(),
810            None,
811            false,
812            false,
813            false,
814            true,
815        )
816        .unwrap_err();
817        assert!(err.to_string().contains("--insecure"));
818    }
819
820    #[test]
821    fn detect_source_rejects_insecure_for_local_archive() {
822        let dir = tempfile::tempdir().unwrap();
823        let path = dir.path().join("plugin.tar.gz");
824        std::fs::write(&path, b"fake archive bytes").unwrap();
825        let err =
826            detect_source(path.to_str().unwrap(), None, false, false, false, true).unwrap_err();
827        assert!(err.to_string().contains("--insecure"));
828    }
829
830    #[test]
831    fn format_source_renders_each_kind() {
832        assert_eq!(
833            format_source(Some(
834                &serde_json::json!({"type":"local_dir","path":"/tmp/x"})
835            )),
836            "local_dir:/tmp/x"
837        );
838        assert_eq!(
839            format_source(Some(
840                &serde_json::json!({"type":"local_archive","path":"/tmp/x.tar.gz"})
841            )),
842            "local_archive:/tmp/x.tar.gz"
843        );
844        assert_eq!(
845            format_source(Some(
846                &serde_json::json!({"type":"url","url":"https://example.com/x.tar.gz"})
847            )),
848            "url:https://example.com/x.tar.gz"
849        );
850        assert_eq!(format_source(None), "-");
851    }
852}