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