Skip to main content

bamboo_server/
plugin_source.rs

1//! Plugin source staging (Wave 2 § Installer-core agent, `PLUGIN_PLAN.md`
2//! Deliverable B): turns whatever the caller (CLI/HTTP) pointed the
3//! installer at — a local directory, a local `.zip`/`.tar.gz`/`.tgz`
4//! archive, or a URL — into a validated bundle at `plugins_dir()/<id>/`,
5//! ready to hand to [`bamboo_plugin::PluginInstaller::install`].
6//!
7//! # The three sources
8//!
9//! - [`PluginSourceInput::LocalDir`] — copies the directory tree.
10//! - [`PluginSourceInput::LocalArchive`] — unpacks a `.zip`/`.tar.gz`/`.tgz`.
11//!   If the archive wraps everything in a single top-level directory (the
12//!   common `tar czf bundle.tar.gz plugin-name/` convention), that directory
13//!   is flattened up so `plugin.json` ends up at the bundle root either way.
14//! - [`PluginSourceInput::Url`] — fetches the manifest bundle (a bare
15//!   `plugin.json`, content-only and typically an MCP server backed entirely
16//!   by a downloadable binary — e.g. a nova-style plugin with no bundled
17//!   skills/prompts — or an archive containing one, same flattening rule as
18//!   `LocalArchive`). **Three trust layers, stacked, enforced in
19//!   [`fetch_manifest_bundle`] in this order:**
20//!
21//!   1. **Host allowlist (source authorization)** — is the URL's `<host><path>`
22//!      one the operator has trusted (`bamboo_config::PluginTrustConfig::trusted_hosts`)?
23//!      Refused BEFORE any network access ([`PluginError::UntrustedHost`])
24//!      unless `allow_untrusted_host` is set.
25//!   2. **Signature (publisher authenticity)** — after the bundle is
26//!      downloaded, does its `<url>.sig` sidecar (a raw 64-byte ed25519
27//!      signature, hex-encoded, over the exact bundle bytes) verify against
28//!      any `bamboo_config::TrustedKey` in `trusted_keys`? Refused
29//!      ([`PluginError::UnsignedOrUntrustedSignature`]) unless `allow_unsigned`
30//!      is set.
31//!   3. **Checksum (integrity)** — same sha256 pin as before, EXCEPT a
32//!      verified signature from layer 2 already proves integrity+authenticity
33//!      more strongly than a pasted hash could, so it SATISFIES this layer's
34//!      requirement even with neither `sha256` nor `allow_unverified` given
35//!      (an `allow_unsigned` bypass grants no such credit — an unsigned
36//!      install still needs its own sha256/allow_unverified exactly as
37//!      before). See [`fetch_manifest_bundle`] for the precise precedence.
38//!
39//!   A pasted checksum ALONE never establishes source trust — it is circular
40//!   if the attacker controls the page the checksum was copied from — which
41//!   is why layers 1 and 2 exist independently of layer 3.
42//!
43//!   Byte-authenticity note: the host allowlist only vets the FIRST hop's
44//!   `<host><path>`, not wherever an HTTP redirect might lead — a signature
45//!   or checksum is what actually authenticates the downloaded bytes, so
46//!   redirects are followed whenever either will be checked, but disabled
47//!   entirely for the fully-opted-out `allow_unsigned && sha256.is_none()`
48//!   case, where the host allowlist is the sole control (see
49//!   [`http_client_no_redirects`]).
50//!
51//! # `--insecure` / `plugin_trust.enforcement`: skip ALL three layers at once
52//!
53//! The three `allow_*` opt-outs above are per-layer. On top of them,
54//! [`PluginSourceInput::Url::insecure`] is a convenience AGGREGATE — set it
55//! (CLI: `--insecure`; HTTP: `"insecure": true` on the `url` source) and
56//! [`fetch_manifest_bundle`] treats `allow_untrusted_host`, `allow_unsigned`
57//! AND `allow_unverified` as all `true` for that one install, without the
58//! caller spelling out all three. There is also a PERSISTENT, config-level
59//! form for an operator who never wants to pass flags at all:
60//! `bamboo_config::PluginTrustConfig::enforcement` set to
61//! `PluginTrustEnforcement::Off` makes EVERY `url` install/update behave as
62//! if `--insecure` were passed, with no per-install flag needed. Precedence,
63//! in both cases:
64//!
65//! - The aggregate ONLY turns per-layer checks OFF — it never turns off a
66//!   check the caller opted INTO. A supplied `sha256` is still hashed and
67//!   compared; a mismatch is still [`PluginError::BundleVerificationFailed`],
68//!   `--insecure`/`enforcement: off` or not. So `--insecure --sha256 <hex>`
69//!   means "skip host/signature enforcement AND the bare
70//!   sha256-required-by-default rule, but still verify THIS hash".
71//! - The per-layer flags keep working independently — a caller who wants to
72//!   waive just the host allowlist (say) still passes
73//!   `--allow-untrusted-host` alone; the aggregate is a shortcut for "all
74//!   three", not a replacement for them.
75//! - `plugin_trust.enforcement` defaults to `Strict` (secure by default) for
76//!   both a fresh config and one with no `plugin_trust.enforcement` key at
77//!   all — this is opt-in relaxation, never a silent weakening.
78//!
79//! Every install where the aggregate is active — via `insecure: true` on the
80//! request OR `plugin_trust.enforcement: off` — logs a prominent
81//! `tracing::warn!` naming the source URL, and records `insecure: true` in
82//! the resulting `PluginSource::Url` provenance (`bamboo plugin list`/audit
83//! can then tell these installs apart from ones where the same three
84//! individual `allow_*` flags merely happened to all be set). A server
85//! booting with `plugin_trust.enforcement: off` also logs its own startup
86//! warning (see `AppState::new`), since that setting silently affects EVERY
87//! future install, not just one command invocation.
88//!
89//!   THEN, separately, for [`Platform::current`] (if the manifest declares an
90//!   artifact for it), fetches the per-platform binary archive named in
91//!   `manifest.artifacts`, verifies its sha256 BEFORE unpacking (mandatory —
92//!   a URL plugin ships a binary that gets executed), and places the single
93//!   expected executable at `bin/<platform>/<id>[.exe]` per
94//!   [`bamboo_plugin::manifest::PluginArtifact`]'s archive contract. This
95//!   artifact-sha256 check is unaffected by the host/signature layers above
96//!   (the artifact URL is declared inside a manifest that has ALREADY passed
97//!   all three trust layers) — it remains defense in depth for the binary
98//!   specifically, closing the gap where the artifact's own declared hash
99//!   lives inside the bundle that carries it.
100//!
101//! All three paths run the SAME safety checks: [`PluginManifest::validate`]
102//! before anything is committed to `plugins_dir()`, and path-traversal-safe
103//! archive extraction (a zip entry's [`zip::read::ZipFile::enclosed_name`]
104//! rejects `..`/absolute entries outright; a tar entry's path is checked for
105//! `ParentDir`/root/prefix components before extraction) — a malicious
106//! archive must not be able to write outside its own staging directory.
107//!
108//! # Swap safety (why an upgrade doesn't lose the old bundle on failure)
109//!
110//! `plugin_dir` is a fixed path per id (`plugins_dir()/<id>/`), so an upgrade
111//! necessarily replaces whatever is already there. [`stage_plugin_source`]
112//! builds the new bundle in a scratch `.staging-*` directory first (so a bad
113//! source — invalid manifest, failed download, sha256 mismatch — never
114//! touches the existing install), then — only once the new bundle validates
115//! — moves the OLD `plugin_dir` aside to a `.backup-*` directory (not
116//! deleted) before renaming the staging directory into place. The returned
117//! [`StagedPlugin`] carries that backup path privately: [`StagedPlugin::commit`]
118//! deletes it (call after a successful `install()`), [`StagedPlugin::rollback`]
119//! removes the half-installed new bundle and restores the backup (call after
120//! a failed `install()`). [`install_plugin_from_source`] wires this up
121//! automatically and is the seam CLI/HTTP callers should use end to end.
122//!
123//! Residual gap (documented, not solved here): the plugin_dir swap itself and
124//! `install()`'s own capability-registration rollback (see
125//! `crate::plugin_installer`'s module docs) are two separate best-effort
126//! steps, not one atomic transaction. If the process crashes between the
127//! swap and `install()` returning, a retry is still safe (staging always
128//! starts from a fresh scratch dir; a leftover `.backup-*`/`.staging-*` dir
129//! is inert and can be swept by an operator or a future cleanup pass) but is
130//! not automatic today.
131//!
132//! # Known follow-ups (deferred — tracked here, not fixed on this branch)
133//!
134//! - **URL content-bundle integrity pin: IMPLEMENTED (secure by default).**
135//!   Previously only the per-platform BINARY artifact was sha256-pinned
136//!   (`PluginArtifact.sha256`, verified in [`fetch_and_place_artifact`]),
137//!   while the `plugin.json` / content archive fetched by
138//!   [`fetch_manifest_bundle`] was trusted on HTTPS alone — a MITM or a
139//!   compromised host could serve a tampered bundle, and since the binary's
140//!   sha256 is DECLARED INSIDE that same untrusted manifest, tampering the
141//!   bundle could rewrite the artifact hash too (the trust chain was
142//!   circular). Fixed: [`PluginSourceInput::Url`] now carries a `sha256`
143//!   (the expected hash of the downloaded bundle) and an `allow_unverified`
144//!   opt-out. [`fetch_manifest_bundle`] verifies the bundle's actual sha256
145//!   against it BEFORE any extraction/parsing on a mismatch
146//!   ([`PluginError::BundleVerificationFailed`]); with neither a `sha256`
147//!   nor `allow_unverified: true`, the fetch is refused up front — before
148//!   the URL is ever requested — with [`PluginError::ChecksumRequired`]. A
149//!   URL install can therefore no longer just download-and-trust any
150//!   tar.gz. The verified bundle sha256 (not the binary artifact's) is what
151//!   `PluginSource::Url.sha256` records for provenance/audit.
152//! - **Source-TRUST layer: IMPLEMENTED** (host allowlist + ed25519 publisher
153//!   signature — see the module-level "three trust layers" summary above and
154//!   [`fetch_manifest_bundle`]). A sha256 pin alone only proves "this is the
155//!   bytes the installer expected", not "an entity I trust produced them" —
156//!   and worse, a checksum pasted from the SAME page as a malicious URL is
157//!   circular, proving nothing about the source. `bamboo_config::PluginTrustConfig`
158//!   (`trusted_hosts` + `trusted_keys`, both user-editable in `config.json`)
159//!   closes that: a URL install now also needs an operator-trusted host and
160//!   (absent `allow_unsigned`) a bundle signature verifying against a trusted
161//!   key. Still deferred:
162//!   - **SSRF guard**, described next.
163//! - **No SSRF guard on URL fetch.** [`download_bytes`] will fetch any URL,
164//!   including `http://169.254.169.254/...` (cloud metadata) or private-range
165//!   / loopback addresses. In a hosted/multi-tenant deployment a plugin-install
166//!   URL is an SSRF vector. A private-IP / metadata-endpoint blocklist (or an
167//!   allowlist of plugin registries) is a threat-model call for the deploy
168//!   layer; noted here so it isn't forgotten.
169//! - **`prompt-presets.json`'s `save_store` is non-atomic** (`fs::write` in
170//!   place, pre-existing behaviour shared with the HTTP prompt-preset
171//!   handlers): a crash mid-write can truncate `prompt-presets.json`. A
172//!   write-to-temp-then-rename would make it atomic, matching what
173//!   `bamboo_plugin::registry::InstalledPlugins::save` (`installed.json`) now
174//!   does; deferred here as a change to a shared, pre-existing storage
175//!   helper rather than this branch's new code.
176//! - **The `plugin_dir` swap in [`stage_plugin_source`] runs BEFORE
177//!   `PLUGIN_OP_LOCK` is acquired** (that lock is taken inside
178//!   `ServerPluginInstaller::install`/`uninstall`, in `crate::plugin_installer`,
179//!   which only runs after staging returns). Two concurrent installs/updates
180//!   of the SAME plugin id could therefore race the backup-rename +
181//!   staging-rename swap itself (not just the capability-registration steps
182//!   the lock does protect). Plugin ops are rare/operator-driven, not
183//!   concurrent by design, so this is accepted as a documented gap rather
184//!   than moving the lock acquisition into this module; a real fix would
185//!   need `stage_plugin_source` and `PluginInstaller::install` to share one
186//!   lock scope (a bigger seam change than this branch's fixes).
187
188use std::path::{Path, PathBuf};
189use std::sync::OnceLock;
190
191use bamboo_config::PluginTrustConfig;
192use bamboo_plugin::manifest::Platform;
193use bamboo_plugin::{
194    InstallDisposition, InstalledPlugin, PluginError, PluginInstaller, PluginManifest,
195    PluginResult, PluginSource,
196};
197use ed25519_dalek::Verifier;
198
199/// What the caller pointed the installer at.
200#[derive(Debug, Clone)]
201pub enum PluginSourceInput {
202    /// A local directory containing `plugin.json` at its root.
203    LocalDir(PathBuf),
204    /// A local `.zip` / `.tar.gz` / `.tgz` archive containing `plugin.json`
205    /// (at its root, or under a single top-level directory).
206    LocalArchive(PathBuf),
207    /// A URL to either a bare `plugin.json` or an archive containing one
208    /// (same root-or-single-subdir rule as `LocalArchive`). Three trust
209    /// layers, all enforced in [`fetch_manifest_bundle`] (see the module
210    /// docs' "three trust layers" summary):
211    ///
212    /// - `allow_untrusted_host`: opt out of the host allowlist
213    ///   (`bamboo_config::PluginTrustConfig::trusted_hosts`) — see
214    ///   [`PluginError::UntrustedHost`].
215    /// - `allow_unsigned`: opt out of requiring the bundle's `.sig` to verify
216    ///   against a trusted key — see [`PluginError::UnsignedOrUntrustedSignature`].
217    /// - `sha256`/`allow_unverified`: the checksum layer, unchanged from
218    ///   before EXCEPT a verified signature now also satisfies it (see
219    ///   [`fetch_manifest_bundle`]) — see [`PluginError::ChecksumRequired`].
220    ///
221    /// Plus `insecure`: the convenience AGGREGATE opt-out over all three
222    /// above (equivalent to setting `allow_untrusted_host`, `allow_unsigned`
223    /// AND `allow_unverified` together for THIS install) — see the module
224    /// docs' "`--insecure` / `plugin_trust.enforcement`" section. A supplied
225    /// `sha256` is still verified even when `insecure` is set (`insecure`
226    /// only turns checks OFF; it never turns a check the caller opted INTO
227    /// off too).
228    Url {
229        url: String,
230        sha256: Option<String>,
231        allow_unverified: bool,
232        allow_untrusted_host: bool,
233        allow_unsigned: bool,
234        insecure: bool,
235    },
236}
237
238/// The result of staging a [`PluginSourceInput`] into `plugins_dir()/<id>/`:
239/// the validated manifest (so a caller can preview `manifest.provides` —
240/// what the install WILL register — before committing to `install()`), the
241/// final on-disk `plugin_dir`, and the [`PluginSource`] provenance record
242/// `install()` expects. See the module docs for the backup/swap contract.
243#[derive(Debug)]
244pub struct StagedPlugin {
245    pub manifest: PluginManifest,
246    pub plugin_dir: PathBuf,
247    pub source: PluginSource,
248    /// The previous `plugin_dir` contents, moved aside rather than deleted
249    /// when staging replaced an existing install (`None` on a fresh
250    /// install). Private: only [`StagedPlugin::commit`] /
251    /// [`StagedPlugin::rollback`] act on it.
252    backup_dir: Option<PathBuf>,
253}
254
255impl StagedPlugin {
256    /// Call after a successful `install()`: the swap is final, drop the
257    /// pre-upgrade backup for good. A no-op on a fresh install.
258    pub async fn commit(self) {
259        if let Some(backup) = self.backup_dir {
260            let _ = tokio::fs::remove_dir_all(&backup).await;
261        }
262    }
263
264    /// Call after a failed `install()`: undo the swap. Removes the
265    /// half-installed new bundle and, on an upgrade, restores the pre-upgrade
266    /// files so the plugin is left exactly as it was.
267    pub async fn rollback(self) {
268        let _ = tokio::fs::remove_dir_all(&self.plugin_dir).await;
269        if let Some(backup) = self.backup_dir {
270            let _ = tokio::fs::rename(&backup, &self.plugin_dir).await;
271        }
272    }
273}
274
275/// Stage `input` into `plugins_root/<id>/`, validating the manifest before
276/// anything is committed. Returns a [`StagedPlugin`] ready to hand to
277/// [`bamboo_plugin::PluginInstaller::install`] — call [`StagedPlugin::commit`]
278/// or [`StagedPlugin::rollback`] once you know whether `install()` succeeded
279/// (or just use [`install_plugin_from_source`], which does both for you).
280pub async fn stage_plugin_source(
281    input: PluginSourceInput,
282    plugins_root: &Path,
283    trust: &PluginTrustConfig,
284) -> PluginResult<StagedPlugin> {
285    stage_plugin_source_inner(input, plugins_root, trust, MAX_DECOMPRESSED_BYTES).await
286}
287
288/// Test-only seam for [`stage_plugin_source`] that lets a test inject a small
289/// `max_decompressed_bytes` cap (the production cap, [`MAX_DECOMPRESSED_BYTES`],
290/// is a generous 2 GiB — not practical to actually exceed in a unit test).
291/// Exercises the exact same staging/swap machinery as the production path,
292/// just with the archive-extraction ceiling parameterized.
293#[cfg(test)]
294pub(crate) async fn stage_plugin_source_with_decompressed_cap(
295    input: PluginSourceInput,
296    plugins_root: &Path,
297    trust: &PluginTrustConfig,
298    max_decompressed_bytes: u64,
299) -> PluginResult<StagedPlugin> {
300    stage_plugin_source_inner(input, plugins_root, trust, max_decompressed_bytes).await
301}
302
303async fn stage_plugin_source_inner(
304    input: PluginSourceInput,
305    plugins_root: &Path,
306    trust: &PluginTrustConfig,
307    max_decompressed_bytes: u64,
308) -> PluginResult<StagedPlugin> {
309    tokio::fs::create_dir_all(plugins_root).await?;
310    let staging_dir = plugins_root.join(format!(".staging-{}", uuid::Uuid::new_v4()));
311    tokio::fs::create_dir_all(&staging_dir).await?;
312
313    let staged = stage_into(&input, &staging_dir, trust, max_decompressed_bytes).await;
314    let (manifest, source) = match staged {
315        Ok(pair) => pair,
316        Err(error) => {
317            let _ = tokio::fs::remove_dir_all(&staging_dir).await;
318            return Err(error);
319        }
320    };
321
322    if let Err(error) = manifest.validate() {
323        let _ = tokio::fs::remove_dir_all(&staging_dir).await;
324        return Err(error);
325    }
326
327    let plugin_dir = plugins_root.join(&manifest.id);
328    let backup_dir = if tokio::fs::try_exists(&plugin_dir).await.unwrap_or(false) {
329        let backup = plugins_root.join(format!(".backup-{}-{}", manifest.id, uuid::Uuid::new_v4()));
330        if let Err(error) = tokio::fs::rename(&plugin_dir, &backup).await {
331            let _ = tokio::fs::remove_dir_all(&staging_dir).await;
332            return Err(PluginError::Io(error));
333        }
334        Some(backup)
335    } else {
336        None
337    };
338
339    if let Err(rename_error) = tokio::fs::rename(&staging_dir, &plugin_dir).await {
340        // Cross-device (e.g. staging on a different mount) — fall back to a
341        // recursive copy, then remove the staging dir.
342        if let Err(copy_error) = copy_dir_recursive(&staging_dir, &plugin_dir).await {
343            let _ = tokio::fs::remove_dir_all(&staging_dir).await;
344            // Restore the backup we just moved aside, if any, since the swap
345            // never actually happened.
346            if let Some(backup) = &backup_dir {
347                let _ = tokio::fs::rename(backup, &plugin_dir).await;
348            }
349            tracing::warn!(%rename_error, %copy_error, "failed to stage plugin bundle into place");
350            return Err(copy_error);
351        }
352        let _ = tokio::fs::remove_dir_all(&staging_dir).await;
353    }
354
355    Ok(StagedPlugin {
356        manifest,
357        plugin_dir,
358        source,
359        backup_dir,
360    })
361}
362
363/// Stage + `install()` + commit/rollback, in one call — the seam CLI/HTTP
364/// callers should use. See the module docs.
365pub async fn install_plugin_from_source(
366    installer: &dyn PluginInstaller,
367    input: PluginSourceInput,
368    plugins_root: &Path,
369    trust: &PluginTrustConfig,
370    disposition: InstallDisposition,
371) -> PluginResult<InstalledPlugin> {
372    let staged = stage_plugin_source(input, plugins_root, trust).await?;
373    let manifest = staged.manifest.clone();
374    let plugin_dir = staged.plugin_dir.clone();
375    let source = staged.source.clone();
376
377    match installer
378        .install(
379            &manifest,
380            &plugin_dir,
381            source,
382            disposition,
383            chrono::Utc::now(),
384        )
385        .await
386    {
387        Ok(entry) => {
388            staged.commit().await;
389            Ok(entry)
390        }
391        Err(error) => {
392            staged.rollback().await;
393            Err(error)
394        }
395    }
396}
397
398async fn stage_into(
399    input: &PluginSourceInput,
400    staging_dir: &Path,
401    trust: &PluginTrustConfig,
402    max_decompressed_bytes: u64,
403) -> PluginResult<(PluginManifest, PluginSource)> {
404    match input {
405        PluginSourceInput::LocalDir(path) => {
406            copy_dir_recursive(path, staging_dir).await?;
407            let manifest = read_and_parse_manifest(staging_dir).await?;
408            Ok((manifest, PluginSource::LocalDir { path: path.clone() }))
409        }
410        PluginSourceInput::LocalArchive(path) => {
411            let bytes = tokio::fs::read(path).await?;
412            let kind = detect_archive_kind(&path.to_string_lossy()).ok_or_else(|| {
413                PluginError::InvalidManifest(format!(
414                    "unsupported archive extension for '{}': expected .zip/.tar.gz/.tgz",
415                    path.display()
416                ))
417            })?;
418            extract_archive(
419                bytes,
420                kind,
421                staging_dir.to_path_buf(),
422                max_decompressed_bytes,
423            )
424            .await?;
425            flatten_if_single_subdir(staging_dir).await?;
426            let manifest = read_and_parse_manifest(staging_dir).await?;
427            Ok((manifest, PluginSource::LocalArchive { path: path.clone() }))
428        }
429        PluginSourceInput::Url {
430            url,
431            sha256,
432            allow_unverified,
433            allow_untrusted_host,
434            allow_unsigned,
435            insecure,
436        } => {
437            let flags = UrlTrustFlags {
438                sha256: sha256.as_deref(),
439                allow_unverified: *allow_unverified,
440                allow_untrusted_host: *allow_untrusted_host,
441                allow_unsigned: *allow_unsigned,
442                insecure: *insecure,
443            };
444            let fetched =
445                fetch_manifest_bundle(url, flags, trust, staging_dir, max_decompressed_bytes)
446                    .await?;
447
448            // Security (issue #479 §4 / open question 6): a manifest
449            // declaring `provides.services` is the highest-trust plugin
450            // artifact kind — a resident, unconstrained process — so it may
451            // NEVER install from a URL source whose bytes weren't
452            // cryptographically signed by a trusted key, no matter which
453            // opt-out flag got it this far (`allow_unsigned` explicitly, or
454            // the `--insecure`/`plugin_trust.enforcement: off` aggregate).
455            // `fetched.signed_by.is_none()` is exactly that "unsigned"
456            // signal regardless of WHY (genuinely unsigned bundle, or an
457            // opt-out that let an unsigned/mismatched one through) — see
458            // `fetch_manifest_bundle`'s layer-2 doc comment. Checked here
459            // (not in `PluginManifest::validate`, which has no visibility
460            // into install-time trust flags/signature results) and BEFORE
461            // the per-platform binary artifact is fetched, so a refused
462            // install downloads no executable at all.
463            if !fetched.manifest.provides.services.is_empty() && fetched.signed_by.is_none() {
464                return Err(PluginError::UnsignedOrUntrustedSignature(format!(
465                    "refusing to install plugin '{}' from '{url}': it declares `provides.services` \
466                     (long-running service plugins are the highest-trust artifact kind) but its \
467                     bundle is unsigned or its signature does not verify against a trusted key — \
468                     `--allow-unsigned`/`--insecure` and `plugin_trust.enforcement: off` are NOT \
469                     honoured for a services-declaring manifest; publish a signature from a \
470                     trusted key instead",
471                    fetched.manifest.id
472                )));
473            }
474
475            // Binary-artifact verification stays as defense in depth (see
476            // the module docs) — its own sha256, declared inside the
477            // now-verified manifest, is checked in
478            // `fetch_and_place_artifact`, but no longer double-duty as the
479            // `PluginSource::Url` provenance hash: that's the bundle's own
480            // verified sha256 now, computed above.
481            fetch_and_place_artifact(&fetched.manifest, staging_dir, max_decompressed_bytes)
482                .await?;
483            Ok((
484                fetched.manifest,
485                PluginSource::Url {
486                    url: url.clone(),
487                    sha256: fetched.verified_sha256,
488                    allow_unverified: *allow_unverified,
489                    allow_untrusted_host: *allow_untrusted_host,
490                    allow_unsigned: *allow_unsigned,
491                    signed_by: fetched.signed_by,
492                    // The AGGREGATE, not the raw per-install `insecure` flag:
493                    // recorded `true` whenever ALL three layers were actually
494                    // skipped for this install, whether that came from the
495                    // per-install flag or from `plugin_trust.enforcement:
496                    // off` (see `fetch_manifest_bundle`) — either way, this is
497                    // the single source of truth for "was this install done
498                    // insecurely" that `plugin list`/audit needs.
499                    insecure: fetched.insecure_aggregate,
500                },
501            ))
502        }
503    }
504}
505
506// ---------------------------------------------------------------------
507// Manifest bundle fetch (URL source)
508// ---------------------------------------------------------------------
509
510/// The caller-supplied bits of a [`PluginSourceInput::Url`] that
511/// [`fetch_manifest_bundle`] needs, grouped into one struct purely to keep
512/// that function's parameter count sane (`PluginSourceInput::Url` itself
513/// carries the same five fields, plus `url`, which stays a separate
514/// top-level parameter since [`fetch_and_verify_signature`] and the sha256
515/// helpers all key off it directly).
516struct UrlTrustFlags<'a> {
517    sha256: Option<&'a str>,
518    allow_unverified: bool,
519    allow_untrusted_host: bool,
520    allow_unsigned: bool,
521    /// The per-install `--insecure` / `"insecure": true` aggregate opt-out
522    /// (see the module docs' "`--insecure` / `plugin_trust.enforcement`"
523    /// section). ORed with `trust.enforcement_is_off()` inside
524    /// [`fetch_manifest_bundle`] to compute the EFFECTIVE aggregate for this
525    /// install — a config-level `enforcement: off` has the same effect as
526    /// this flag without the caller having to set it.
527    insecure: bool,
528}
529
530/// Everything [`fetch_manifest_bundle`] hands back to [`stage_into`].
531struct FetchedBundle {
532    manifest: PluginManifest,
533    /// The verified bundle sha256 (`None` unless a `sha256` was supplied and
534    /// confirmed) — for [`PluginSource::Url`] provenance.
535    verified_sha256: Option<String>,
536    /// The trusted key label the signature verified against (`None` if the
537    /// install proceeded unsigned via `allow_unsigned`/the insecure
538    /// aggregate).
539    signed_by: Option<String>,
540    /// The EFFECTIVE aggregate for this install: `true` when ALL three trust
541    /// layers were skipped, whether that came from the per-install
542    /// `insecure` flag or from `plugin_trust.enforcement: off`. This is what
543    /// [`PluginSource::Url::insecure`] provenance records — see
544    /// [`stage_into`].
545    insecure_aggregate: bool,
546}
547
548/// Fetch `url`: either a bare `plugin.json` or an archive containing one
549/// (same root-or-single-subdir rule as [`PluginSourceInput::LocalArchive`]).
550/// Populates `staging_dir` with whatever the bundle contains (just
551/// `plugin.json` for a bare manifest; the full skills/prompts/workflows tree
552/// for an archive).
553///
554/// **Three trust layers, enforced in this order** (see the module docs'
555/// summary):
556///
557/// 1. **Host allowlist.** Before the URL is even requested: if it is not
558///    `https` with a `<host><path>` matching one of `trust.trusted_hosts` as a
559///    prefix, refuses with [`PluginError::UntrustedHost`] — no network access
560///    happens for a refused install — unless `allow_untrusted_host` is `true`
561///    (logged).
562/// 2. **Signature.** Once the bundle is downloaded, `<url>.sig` is fetched
563///    (a missing/unreachable sidecar is treated identically to a malformed
564///    one — see [`fetch_and_verify_signature`]) and checked against every
565///    `algorithm: "ed25519"` entry in `trust.trusted_keys`. A match records
566///    that key's label; no match refuses with
567///    [`PluginError::UnsignedOrUntrustedSignature`] unless `allow_unsigned`
568///    is `true` (logged).
569/// 3. **Checksum.** If `sha256` is `None`, `allow_unverified` is `false`, AND
570///    the bundle was NOT signature-verified in step 2, refuses with
571///    [`PluginError::ChecksumRequired`] — a verified signature already proves
572///    integrity+authenticity more strongly than a pasted hash, so it
573///    satisfies this layer on its own (an `allow_unsigned` bypass grants no
574///    such credit: an unsigned install still needs its own
575///    `sha256`/`allow_unverified`, exactly as before this branch). If
576///    `sha256` IS given (signed or not), the downloaded bytes are still
577///    hashed and compared (case-insensitive) BEFORE any extraction/parsing —
578///    a mismatch is [`PluginError::BundleVerificationFailed`] regardless of
579///    signature status.
580///
581/// Before any of the three layers run, the EFFECTIVE aggregate is computed:
582/// `flags.insecure || trust.enforcement_is_off()`. When `true`,
583/// `allow_untrusted_host`/`allow_unsigned`/`allow_unverified` are all treated
584/// as `true` for the rest of this call (see the module docs'
585/// "`--insecure` / `plugin_trust.enforcement`" section) and a prominent
586/// `tracing::warn!` names the source URL — this does NOT waive the `sha256`
587/// check itself: a supplied hash is still verified in step 3 below.
588///
589/// Returns a [`FetchedBundle`]: the parsed manifest, the verified bundle
590/// sha256 (`None` unless a `sha256` was supplied and confirmed — for
591/// [`PluginSource::Url`] provenance), the trusted key label the signature
592/// verified against (`None` if the install proceeded unsigned via
593/// `allow_unsigned`/the aggregate), and the effective insecure-aggregate flag
594/// itself (for `PluginSource::Url::insecure` provenance).
595async fn fetch_manifest_bundle(
596    url: &str,
597    flags: UrlTrustFlags<'_>,
598    trust: &PluginTrustConfig,
599    staging_dir: &Path,
600    max_decompressed_bytes: u64,
601) -> PluginResult<FetchedBundle> {
602    let UrlTrustFlags {
603        sha256,
604        allow_unverified,
605        allow_untrusted_host,
606        allow_unsigned,
607        insecure,
608    } = flags;
609
610    // The convenience aggregate: a per-install `--insecure` flag OR a
611    // config-level `plugin_trust.enforcement: off` both mean "skip all three
612    // layers for this install" — computed once, up front, so every layer
613    // below sees the SAME effective flags regardless of which of the two
614    // triggered it. Shadowing the original `allow_*` bindings means the rest
615    // of this function needs no further special-casing.
616    let insecure_aggregate = insecure || trust.enforcement_is_off();
617    if insecure_aggregate {
618        tracing::warn!(
619            %url,
620            "installing plugin from '{url}' with ALL trust checks disabled (insecure) — host \
621             allowlist, signature and checksum-required-by-default are all skipped for this \
622             install (a supplied --sha256, if any, is still verified)"
623        );
624    }
625    let allow_untrusted_host = allow_untrusted_host || insecure_aggregate;
626    let allow_unsigned = allow_unsigned || insecure_aggregate;
627    let allow_unverified = allow_unverified || insecure_aggregate;
628
629    // Layer 1: host allowlist — refuse BEFORE any network access.
630    if !trust.is_host_trusted(url) {
631        if !allow_untrusted_host {
632            return Err(PluginError::UntrustedHost(format!(
633                "refusing to install plugin bundle from '{url}': its host is not in the \
634                 `plugin_trust.trusted_hosts` allowlist (config.json) — add a matching \
635                 host+path prefix there, or explicitly accept the risk (CLI: \
636                 `--allow-untrusted-host`; HTTP: `\"allow_untrusted_host\": true`)"
637            )));
638        }
639        tracing::warn!(
640            %url,
641            "installing plugin bundle from a host outside `plugin_trust.trusted_hosts` \
642             (allow_untrusted_host opt-out)"
643        );
644    }
645
646    // Redirect policy (BLOCKER 1 fix): whether the downloaded bytes WILL be
647    // cryptographically authenticated determines whether it's safe to follow
648    // a redirect. A signature is REQUIRED whenever `!allow_unsigned` — even
649    // though it hasn't been fetched/checked yet, an unverified-but-required
650    // signature refuses the install below regardless of which host actually
651    // served the bytes, so following a redirect to get here is harmless.
652    // Likewise a supplied `sha256` is checked (and refused on mismatch) below
653    // regardless of the serving host. Only when NEITHER control is in play —
654    // `allow_unsigned` AND no `sha256` — is the host allowlist the SOLE
655    // authority over where these bytes came from; it only vetted this exact
656    // URL, so redirects must be disabled in that case (see
657    // `http_client_no_redirects`). The SAME client is used for both the
658    // bundle fetch and the `.sig` fetch below, for one install.
659    let bytes_will_be_authenticated = !allow_unsigned || sha256.is_some();
660    let client = if bytes_will_be_authenticated {
661        http_client_following_redirects()
662    } else {
663        http_client_no_redirects()
664    };
665
666    let bytes = download_bytes(client, url, MAX_DOWNLOAD_BYTES).await?;
667
668    // Layer 2: signature — a valid signature is a STRONGER integrity +
669    // authenticity guarantee than a pasted checksum (see layer 3 below).
670    let signed_by = fetch_and_verify_signature(client, url, &bytes, &trust.trusted_keys).await;
671    if signed_by.is_none() {
672        if !allow_unsigned {
673            return Err(PluginError::UnsignedOrUntrustedSignature(format!(
674                "refusing to install plugin bundle from '{url}': it is unsigned, or its \
675                 '{url}.sig' does not verify against any key in `plugin_trust.trusted_keys` \
676                 (config.json) — publish a signature from a trusted key, or explicitly accept \
677                 the risk (CLI: `--allow-unsigned`; HTTP: `\"allow_unsigned\": true`)"
678            )));
679        }
680        tracing::warn!(
681            %url,
682            "installing an unsigned (or untrusted-signature) plugin bundle (allow_unsigned opt-out)"
683        );
684    }
685
686    // Layer 3: checksum — superseded by a verified signature (layer 2), but
687    // otherwise unchanged.
688    if sha256.is_none() && !allow_unverified && signed_by.is_none() {
689        return Err(PluginError::ChecksumRequired(format!(
690            "refusing to install plugin bundle from '{url}' without a checksum — pass the \
691             bundle's sha256 (from the release page / a trusted source) to verify it before \
692             install (CLI: `--sha256 <hex>`; HTTP: `\"sha256\": \"<hex>\"` on the url source), \
693             or explicitly accept the risk of an unverified download (CLI: \
694             `--allow-unverified`; HTTP: `\"allow_unverified\": true`)"
695        )));
696    }
697
698    let verified_sha256 = match sha256 {
699        Some(expected) => {
700            let actual = sha256_hex(&bytes);
701            if !actual.eq_ignore_ascii_case(expected) {
702                return Err(PluginError::BundleVerificationFailed(format!(
703                    "sha256 mismatch for plugin bundle '{url}': expected {expected}, downloaded \
704                     bytes hash to {actual} — refusing to unpack (the bundle may be tampered, \
705                     corrupted, or the wrong sha256 was supplied)"
706                )));
707            }
708            Some(actual)
709        }
710        None => {
711            if signed_by.is_none() {
712                tracing::warn!(
713                    %url,
714                    "installing plugin bundle from a URL with no checksum verification \
715                     (allow_unverified opt-out) — the download is trusted on HTTPS alone"
716                );
717            }
718            None
719        }
720    };
721
722    let manifest = if let Some(kind) = detect_archive_kind(url) {
723        extract_archive(
724            bytes,
725            kind,
726            staging_dir.to_path_buf(),
727            max_decompressed_bytes,
728        )
729        .await?;
730        flatten_if_single_subdir(staging_dir).await?;
731        read_and_parse_manifest(staging_dir).await?
732    } else {
733        let raw = String::from_utf8(bytes).map_err(|_| {
734            PluginError::InvalidManifest(format!("manifest at '{url}' is not valid UTF-8"))
735        })?;
736        tokio::fs::create_dir_all(staging_dir).await?;
737        tokio::fs::write(staging_dir.join("plugin.json"), &raw).await?;
738        PluginManifest::parse_str(&raw)?
739    };
740
741    Ok(FetchedBundle {
742        manifest,
743        verified_sha256,
744        signed_by,
745        insecure_aggregate,
746    })
747}
748
749/// Fetch `<url>.sig` and verify it against `bundle_bytes` for every
750/// `algorithm: "ed25519"` entry in `trusted_keys`. Returns the label of the
751/// FIRST trusted key the signature verifies against, or `None` if the
752/// sidecar is missing/unfetchable, malformed (not 128 hex chars — a raw
753/// 64-byte ed25519 signature, trailing whitespace trimmed), or does not
754/// verify against any trusted key. All of those failure modes are treated
755/// identically on purpose: an attacker serving a garbage/absent `.sig` must
756/// not be distinguishable from "genuinely unsigned" by anything this
757/// function returns.
758async fn fetch_and_verify_signature(
759    client: &reqwest::Client,
760    url: &str,
761    bundle_bytes: &[u8],
762    trusted_keys: &[bamboo_config::TrustedKey],
763) -> Option<String> {
764    // Plain release-asset URLs (no query string) are the supported case: this
765    // just appends `.sig`, which would misplace the suffix AFTER a query
766    // string on a URL like `.../plugin.json?token=...` (`.../plugin.json?token=....sig`,
767    // not the sidecar). Plugin bundle URLs are typically bare release-asset
768    // URLs with no query string; if that ever needs to change, insert `.sig`
769    // before the `?` instead of blindly appending it.
770    let sig_url = format!("{url}.sig");
771    let sig_bytes = download_bytes(client, &sig_url, MAX_SIGNATURE_DOWNLOAD_BYTES)
772        .await
773        .ok()?;
774    let sig_text = String::from_utf8(sig_bytes).ok()?;
775    let sig_raw = hex::decode(sig_text.trim()).ok()?;
776    let sig_array: [u8; 64] = sig_raw.try_into().ok()?;
777    let signature = ed25519_dalek::Signature::from_bytes(&sig_array);
778
779    for key in trusted_keys {
780        if !key.algorithm.eq_ignore_ascii_case("ed25519") {
781            continue;
782        }
783        let Ok(pub_raw) = hex::decode(&key.public_key) else {
784            continue;
785        };
786        let Ok(pub_array) = <[u8; 32]>::try_from(pub_raw.as_slice()) else {
787            continue;
788        };
789        let Ok(verifying_key) = ed25519_dalek::VerifyingKey::from_bytes(&pub_array) else {
790            continue;
791        };
792        if verifying_key.verify(bundle_bytes, &signature).is_ok() {
793            return Some(key.label.clone());
794        }
795    }
796    None
797}
798
799/// Per-platform binary artifact fetch (URL source only). Fetches the
800/// artifact declared for [`Platform::current`] (a no-op if the manifest
801/// declares none for this platform — not every plugin needs a binary),
802/// verifies its sha256 BEFORE unpacking, and places the single expected
803/// executable at `<staging_dir>/bin/<platform>/<id>[.exe]`.
804///
805/// This stays as defense in depth alongside [`fetch_manifest_bundle`]'s
806/// bundle-sha256 check (see the module docs): the artifact hash is declared
807/// INSIDE the manifest, so on its own it only proves "the binary matches
808/// what this bundle's manifest says", not "this bundle itself is what the
809/// caller expected" — that's what the bundle-level check now provides. The
810/// verified artifact sha256 is therefore no longer surfaced to the caller
811/// (it used to double as [`PluginSource::Url`]'s provenance hash before the
812/// bundle-level check existed); this function's contract is purely
813/// verify-then-place.
814async fn fetch_and_place_artifact(
815    manifest: &PluginManifest,
816    staging_dir: &Path,
817    max_decompressed_bytes: u64,
818) -> PluginResult<()> {
819    let Some(platform) = Platform::current() else {
820        return Ok(());
821    };
822    let Some(artifact) = manifest.artifacts.get(platform.as_str()) else {
823        return Ok(());
824    };
825
826    // The artifact's sha256 is verified BELOW, unconditionally (no bypass
827    // flag exists for it — see this function's docs) — the downloaded bytes
828    // are always cryptographically authenticated here, so redirects are
829    // always safe to follow for this fetch.
830    let bytes = download_bytes(
831        http_client_following_redirects(),
832        &artifact.url,
833        MAX_DOWNLOAD_BYTES,
834    )
835    .await?;
836    let actual_sha256 = sha256_hex(&bytes);
837    if !actual_sha256.eq_ignore_ascii_case(&artifact.sha256) {
838        return Err(PluginError::ArtifactVerificationFailed(format!(
839            "sha256 mismatch for '{}': manifest declares {}, downloaded bytes hash to {}",
840            artifact.url, artifact.sha256, actual_sha256
841        )));
842    }
843
844    let kind = detect_archive_kind(&artifact.url).ok_or_else(|| {
845        PluginError::InvalidManifest(format!(
846            "artifact url '{}' is not a .zip/.tar.gz/.tgz",
847            artifact.url
848        ))
849    })?;
850
851    let scratch_dir = staging_dir.join(format!(".artifact-scratch-{}", platform.as_str()));
852    extract_archive(bytes, kind, scratch_dir.clone(), max_decompressed_bytes).await?;
853
854    let expected_name = if matches!(platform, Platform::Windows) {
855        format!("{}.exe", manifest.id)
856    } else {
857        manifest.id.clone()
858    };
859    let source_bin = scratch_dir.join(&expected_name);
860    if !tokio::fs::try_exists(&source_bin).await.unwrap_or(false) {
861        let _ = tokio::fs::remove_dir_all(&scratch_dir).await;
862        return Err(PluginError::InvalidManifest(format!(
863            "artifact archive for platform '{}' does not contain the expected root executable '{}'",
864            platform.as_str(),
865            expected_name
866        )));
867    }
868
869    let dest_dir = staging_dir.join("bin").join(platform.as_str());
870    tokio::fs::create_dir_all(&dest_dir).await?;
871    let dest_bin = dest_dir.join(&expected_name);
872    move_file(&source_bin, &dest_bin).await?;
873
874    #[cfg(unix)]
875    {
876        use std::os::unix::fs::PermissionsExt;
877        let mut perms = tokio::fs::metadata(&dest_bin).await?.permissions();
878        perms.set_mode(0o755);
879        tokio::fs::set_permissions(&dest_bin, perms).await?;
880    }
881
882    let _ = tokio::fs::remove_dir_all(&scratch_dir).await;
883    Ok(())
884}
885
886/// `rename`, falling back to copy+remove across a device boundary.
887async fn move_file(source: &Path, dest: &Path) -> PluginResult<()> {
888    if tokio::fs::rename(source, dest).await.is_ok() {
889        return Ok(());
890    }
891    let data = tokio::fs::read(source).await?;
892    tokio::fs::write(dest, data).await?;
893    tokio::fs::remove_file(source).await?;
894    Ok(())
895}
896
897// ---------------------------------------------------------------------
898// HTTP fetch
899// ---------------------------------------------------------------------
900
901/// Client used whenever the downloaded bytes WILL be cryptographically
902/// authenticated — a signature is required (`!allow_unsigned`) or a `sha256`
903/// pin was supplied. Following redirects is safe here: whichever host
904/// actually served the final bytes, the signature/checksum check downstream
905/// refuses on a bad result regardless — this is what lets the default
906/// official-signed-via-CDN flow (GitHub Releases 302-redirecting to
907/// `objects.githubusercontent.com`) and the checksummed flow keep working.
908/// Reuses the workspace's pinned (native-tls) `reqwest` — never construct a
909/// second client/connector here (see `notify_sinks::ntfy`'s identical
910/// pattern).
911fn http_client_following_redirects() -> &'static reqwest::Client {
912    static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
913    CLIENT.get_or_init(|| {
914        reqwest::Client::builder()
915            .redirect(reqwest::redirect::Policy::limited(10))
916            .build()
917            .expect("a reqwest client with only a redirect policy set always builds")
918    })
919}
920
921/// Client used whenever NEITHER a signature nor a checksum will authenticate
922/// the downloaded bytes (`allow_unsigned && sha256.is_none()` — the fully
923/// opted-out "host-only trust" case). The host allowlist (layer 1) only
924/// vetted the FIRST hop's `<host><path>`; a transparent redirect would let
925/// the bytes actually come from anywhere, silently defeating the allowlist
926/// as the sole control. Redirects are disabled so a server that tries to
927/// redirect is refused outright (see [`download_bytes`]) rather than quietly
928/// followed — the approved host must serve the bytes directly.
929fn http_client_no_redirects() -> &'static reqwest::Client {
930    static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
931    CLIENT.get_or_init(|| {
932        reqwest::Client::builder()
933            .redirect(reqwest::redirect::Policy::none())
934            .build()
935            .expect("a reqwest client with only a redirect policy set always builds")
936    })
937}
938
939/// Hard ceiling on any single plugin download (manifest, bundle, or binary
940/// artifact archive). A malicious or misconfigured URL must not be able to
941/// stream an unbounded body into memory (OOM DoS). 256 MiB is generous for a
942/// plugin bundle + one platform binary while still bounding the worst case.
943const MAX_DOWNLOAD_BYTES: u64 = 256 * 1024 * 1024;
944
945/// Hard ceiling on the `.sig` sidecar fetch specifically ([`fetch_and_verify_signature`]).
946/// A valid signature is exactly 128 hex chars (a raw 64-byte ed25519
947/// signature, hex-encoded) — 4 KiB is already wildly generous. Capping it far
948/// below [`MAX_DOWNLOAD_BYTES`] means a malicious/misconfigured host serving
949/// a `.sig` route can't force multiple hundreds of MiB into memory before the
950/// hex-decode simply fails on the (way too long) body.
951const MAX_SIGNATURE_DOWNLOAD_BYTES: u64 = 4 * 1024;
952
953/// Hard ceiling on the TOTAL decompressed bytes any single archive (zip or
954/// tar.gz) may expand to across ALL of its entries combined. Complements
955/// `MAX_DOWNLOAD_BYTES`, which only bounds the COMPRESSED bytes fetched over
956/// the wire — a small, highly-compressible archive (a classic
957/// decompression/"zip bomb") can still expand to many gigabytes on disk with
958/// nothing capping the output side. Enforced incrementally DURING extraction
959/// (see [`copy_capped`]) against the ACTUAL bytes read off the decompression
960/// stream — never an entry's header-declared size, which a crafted archive
961/// can misstate (a zip's `uncompressed_size` field in particular is pure
962/// metadata the reader doesn't have to honor) — so a bomb is aborted close to
963/// this ceiling rather than after it has already exhausted disk. 2 GiB is
964/// generous for any legitimate plugin bundle (skills/prompts/workflows text
965/// plus, at most, one platform binary) while still bounding the worst case.
966const MAX_DECOMPRESSED_BYTES: u64 = 2 * 1024 * 1024 * 1024;
967
968/// Fetch `url` via `client`, capping the body at `max_bytes`. `client`'s
969/// redirect policy is the caller's decision (see
970/// [`http_client_following_redirects`] / [`http_client_no_redirects`]) — this
971/// function additionally refuses outright, with [`PluginError::RedirectRefused`]
972/// (a clean 403-family trust refusal, NOT a 500), if the FINAL response it
973/// receives is itself still a redirect (3xx), which only happens when
974/// `client` was built with `redirect::Policy::none()` and the server actually
975/// tried to redirect: that means the request's trust flags decided the bytes
976/// must come from the vetted host directly (see BLOCKER 1 in the source-trust
977/// review / the module docs), so silently treating the redirect response as
978/// the payload would defeat that decision.
979async fn download_bytes(
980    client: &reqwest::Client,
981    url: &str,
982    max_bytes: u64,
983) -> PluginResult<Vec<u8>> {
984    use futures::StreamExt;
985
986    let response =
987        client.get(url).send().await.map_err(|error| {
988            PluginError::Registration(format!("failed to fetch '{url}': {error}"))
989        })?;
990
991    if response.status().is_redirection() {
992        let status = response.status();
993        // Surface the redirect TARGET (host) so the caller can decide whether
994        // to trust it / add it to `trusted_hosts` — a redirect with no
995        // `Location`, or one whose value isn't valid UTF-8, degrades to a
996        // generic "(unspecified)" rather than failing differently.
997        let location = response
998            .headers()
999            .get(reqwest::header::LOCATION)
1000            .and_then(|value| value.to_str().ok())
1001            .map(str::to_string);
1002        let target = location.as_deref().unwrap_or("(unspecified)");
1003        return Err(PluginError::RedirectRefused(format!(
1004            "refused to follow an HTTP redirect ({status}) from '{url}' to '{target}': for an \
1005             unverified install (no signature, no checksum) the approved host must serve the \
1006             bytes directly, so redirects are not followed — install from the canonical/final \
1007             URL, or provide a signature / `--sha256` (which authenticates the bytes regardless \
1008             of which host serves them), or add the redirect target's host to \
1009             `plugin_trust.trusted_hosts`"
1010        )));
1011    }
1012
1013    let response = response.error_for_status().map_err(|error| {
1014        PluginError::Registration(format!("'{url}' returned an error status: {error}"))
1015    })?;
1016
1017    // Reject up front if the server ADVERTISES an over-cap body (cheap, avoids
1018    // streaming at all)...
1019    if let Some(len) = response.content_length() {
1020        if len > max_bytes {
1021            return Err(PluginError::Registration(format!(
1022                "'{url}' advertises a {len}-byte body, over the {max_bytes}-byte download cap; \
1023                 refusing"
1024            )));
1025        }
1026    }
1027
1028    // ...and ALSO cap the actually-streamed bytes, since Content-Length can be
1029    // absent (chunked) or a lie.
1030    let mut stream = response.bytes_stream();
1031    let mut buffer: Vec<u8> = Vec::new();
1032    while let Some(chunk) = stream.next().await {
1033        let chunk = chunk.map_err(|error| {
1034            PluginError::Registration(format!("failed to read response body of '{url}': {error}"))
1035        })?;
1036        if buffer.len() as u64 + chunk.len() as u64 > max_bytes {
1037            return Err(PluginError::Registration(format!(
1038                "'{url}' streamed more than the {max_bytes}-byte download cap; aborting"
1039            )));
1040        }
1041        buffer.extend_from_slice(&chunk);
1042    }
1043    Ok(buffer)
1044}
1045
1046fn sha256_hex(bytes: &[u8]) -> String {
1047    use sha2::{Digest, Sha256};
1048    let mut hasher = Sha256::new();
1049    hasher.update(bytes);
1050    hex::encode(hasher.finalize())
1051}
1052
1053// ---------------------------------------------------------------------
1054// Archive handling (path-traversal-safe)
1055// ---------------------------------------------------------------------
1056
1057#[derive(Debug, Clone, Copy)]
1058enum ArchiveKind {
1059    Zip,
1060    TarGz,
1061}
1062
1063fn detect_archive_kind(name_or_url: &str) -> Option<ArchiveKind> {
1064    let lower = name_or_url.to_ascii_lowercase();
1065    // Strip a query string/fragment before checking the extension, in case a
1066    // URL looks like `.../plugin.tar.gz?token=...`.
1067    let lower = lower.split(['?', '#']).next().unwrap_or(&lower).to_string();
1068    if lower.ends_with(".zip") {
1069        Some(ArchiveKind::Zip)
1070    } else if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") {
1071        Some(ArchiveKind::TarGz)
1072    } else {
1073        None
1074    }
1075}
1076
1077/// Extract `bytes` (a zip or tar.gz archive) into `dest_dir`, rejecting any
1078/// entry whose path would escape `dest_dir` (traversal / absolute paths), and
1079/// aborting if the TOTAL decompressed output across all entries would exceed
1080/// `max_decompressed_bytes` (decompression-bomb guard — see
1081/// [`MAX_DECOMPRESSED_BYTES`] / [`copy_capped`]). Runs the (synchronous)
1082/// extraction on a blocking thread.
1083async fn extract_archive(
1084    bytes: Vec<u8>,
1085    kind: ArchiveKind,
1086    dest_dir: PathBuf,
1087    max_decompressed_bytes: u64,
1088) -> PluginResult<()> {
1089    tokio::fs::create_dir_all(&dest_dir).await?;
1090    tokio::task::spawn_blocking(move || match kind {
1091        ArchiveKind::Zip => extract_zip_sync(&bytes, &dest_dir, max_decompressed_bytes),
1092        ArchiveKind::TarGz => extract_targz_sync(&bytes, &dest_dir, max_decompressed_bytes),
1093    })
1094    .await
1095    .map_err(|error| {
1096        PluginError::Registration(format!("archive extraction task panicked: {error}"))
1097    })?
1098}
1099
1100/// Copy `reader` into `writer` in small, bounded chunks, tallying bytes into
1101/// `running_total` — which the caller carries ACROSS every entry in the
1102/// archive, so the cap is on the archive's total decompressed output, not
1103/// any one entry — and aborting the moment the cumulative count would exceed
1104/// `max_decompressed_bytes`. Chunked copying (rather than `std::io::copy`
1105/// followed by a size check afterward) keeps the amount ever actually
1106/// written to disk bounded near the cap even for a single maximally
1107/// compressible entry: the whole point of the cap is to stop a small archive
1108/// from exhausting disk, so only checking after a full `io::copy` completed
1109/// would defeat it.
1110fn copy_capped(
1111    reader: &mut impl std::io::Read,
1112    writer: &mut impl std::io::Write,
1113    running_total: &mut u64,
1114    max_decompressed_bytes: u64,
1115) -> PluginResult<()> {
1116    let mut buffer = [0u8; 64 * 1024];
1117    loop {
1118        let bytes_read = reader.read(&mut buffer)?;
1119        if bytes_read == 0 {
1120            return Ok(());
1121        }
1122        *running_total += bytes_read as u64;
1123        if *running_total > max_decompressed_bytes {
1124            return Err(PluginError::InvalidManifest(format!(
1125                "archive expands to more than the {max_decompressed_bytes}-byte decompressed \
1126                 size cap ({running_total} bytes and counting); refusing to unpack (possible \
1127                 decompression bomb)"
1128            )));
1129        }
1130        writer.write_all(&buffer[..bytes_read])?;
1131    }
1132}
1133
1134fn extract_zip_sync(
1135    bytes: &[u8],
1136    dest_dir: &Path,
1137    max_decompressed_bytes: u64,
1138) -> PluginResult<()> {
1139    use std::io::Cursor;
1140
1141    let cursor = Cursor::new(bytes);
1142    let mut archive = zip::ZipArchive::new(cursor)
1143        .map_err(|error| PluginError::InvalidManifest(format!("invalid zip archive: {error}")))?;
1144
1145    let mut total_decompressed_bytes: u64 = 0;
1146
1147    for index in 0..archive.len() {
1148        let mut file = archive.by_index(index).map_err(|error| {
1149            PluginError::InvalidManifest(format!("invalid zip entry at index {index}: {error}"))
1150        })?;
1151        // `enclosed_name()` is the zip crate's own traversal guard: it
1152        // returns `None` for any entry whose name contains `..`, is
1153        // absolute, or otherwise can't be safely joined under `dest_dir`.
1154        let Some(relative_path) = file.enclosed_name() else {
1155            return Err(PluginError::InvalidManifest(format!(
1156                "zip entry '{}' has an unsafe path (traversal/absolute) — refusing to unpack",
1157                file.name()
1158            )));
1159        };
1160        let out_path = dest_dir.join(&relative_path);
1161        if file.is_dir() {
1162            std::fs::create_dir_all(&out_path)?;
1163            continue;
1164        }
1165        if let Some(parent) = out_path.parent() {
1166            std::fs::create_dir_all(parent)?;
1167        }
1168        let mut out_file = std::fs::File::create(&out_path)?;
1169        if let Err(error) = copy_capped(
1170            &mut file,
1171            &mut out_file,
1172            &mut total_decompressed_bytes,
1173            max_decompressed_bytes,
1174        ) {
1175            drop(out_file);
1176            // Defense in depth: remove the partial file we were just writing
1177            // even though the caller (`stage_plugin_source`) also wipes the
1178            // whole staging directory on any `Err` from this function — a
1179            // direct caller of this lower-level helper should never see a
1180            // half-written entry either.
1181            let _ = std::fs::remove_file(&out_path);
1182            return Err(error);
1183        }
1184        drop(out_file);
1185
1186        #[cfg(unix)]
1187        {
1188            use std::os::unix::fs::PermissionsExt;
1189            if let Some(mode) = file.unix_mode() {
1190                std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))?;
1191            }
1192        }
1193    }
1194    Ok(())
1195}
1196
1197fn extract_targz_sync(
1198    bytes: &[u8],
1199    dest_dir: &Path,
1200    max_decompressed_bytes: u64,
1201) -> PluginResult<()> {
1202    use flate2::read::GzDecoder;
1203    use std::path::Component;
1204    use tar::{Archive, EntryType};
1205
1206    let decoder = GzDecoder::new(bytes);
1207    let mut archive = Archive::new(decoder);
1208    let mut total_decompressed_bytes: u64 = 0;
1209    for entry_result in archive.entries()? {
1210        let mut entry = entry_result?;
1211
1212        // SECURITY (symlink/hardlink escape): reject any Symlink or HardLink
1213        // entry outright BEFORE unpacking. `entry.unpack()` is the raw tar API
1214        // — it validates the entry's OWN path (checked below) but NOT a link
1215        // entry's TARGET (`link_name`), which is fully attacker-controlled and
1216        // may be absolute or contain `..`. A malicious bundle could ship
1217        // e.g. `workflows/evil.md` as a symlink to `~/.ssh/id_rsa` or bamboo's
1218        // `config.json`; a later `fs::read_to_string` (register_workflows) would
1219        // follow it and copy the victim's real content into a plugin-visible
1220        // location = arbitrary file exfiltration, and `flatten_if_single_subdir`
1221        // following a symlink-to-a-real-dir could rename/destroy the victim's
1222        // files. A plugin bundle has no legitimate reason to ship a link (same
1223        // rationale as `copy_dir_recursive` skipping symlinks), so refuse the
1224        // whole archive. (Zip is not affected: `extract_zip_sync` writes every
1225        // entry as a fresh regular file via `copy_capped`, so an archived
1226        // "symlink" lands inert as a plain file.)
1227        let entry_type = entry.header().entry_type();
1228        if matches!(entry_type, EntryType::Symlink | EntryType::Link) {
1229            let link_target = entry
1230                .link_name()
1231                .ok()
1232                .flatten()
1233                .map(|path| path.display().to_string())
1234                .unwrap_or_default();
1235            return Err(PluginError::InvalidManifest(format!(
1236                "tar entry '{}' is a {} (target '{link_target}') — plugin bundles must not ship \
1237                 links; refusing to unpack",
1238                entry
1239                    .path()
1240                    .map(|p| p.display().to_string())
1241                    .unwrap_or_default(),
1242                if entry_type == EntryType::Symlink {
1243                    "symlink"
1244                } else {
1245                    "hardlink"
1246                },
1247            )));
1248        }
1249
1250        let relative_path = entry.path()?.into_owned();
1251        let is_unsafe = relative_path.components().any(|component| {
1252            matches!(
1253                component,
1254                Component::ParentDir | Component::RootDir | Component::Prefix(_)
1255            )
1256        });
1257        if is_unsafe {
1258            return Err(PluginError::InvalidManifest(format!(
1259                "tar entry '{}' has an unsafe path (traversal/absolute) — refusing to unpack",
1260                relative_path.display()
1261            )));
1262        }
1263        let out_path = dest_dir.join(&relative_path);
1264
1265        // Directories carry no content to cap — just create and move on
1266        // (mirrors `entry.unpack()`'s own directory handling, which this
1267        // function replaces for content-bearing entries below so the
1268        // decompressed-size cap can be enforced incrementally; see
1269        // `copy_capped`).
1270        if entry_type.is_dir() {
1271            std::fs::create_dir_all(&out_path)?;
1272            continue;
1273        }
1274
1275        if let Some(parent) = out_path.parent() {
1276            std::fs::create_dir_all(parent)?;
1277        }
1278        let mut out_file = std::fs::File::create(&out_path)?;
1279        if let Err(error) = copy_capped(
1280            &mut entry,
1281            &mut out_file,
1282            &mut total_decompressed_bytes,
1283            max_decompressed_bytes,
1284        ) {
1285            drop(out_file);
1286            // Defense in depth: see the identical cleanup in
1287            // `extract_zip_sync` — the whole staging dir is also wiped by
1288            // the caller, but a direct caller of this helper shouldn't see
1289            // a half-written entry either.
1290            let _ = std::fs::remove_file(&out_path);
1291            return Err(error);
1292        }
1293        drop(out_file);
1294
1295        // Preserve the entry's permission bits (matches `entry.unpack()`'s
1296        // own behaviour, which this manual copy replaces).
1297        #[cfg(unix)]
1298        {
1299            use std::os::unix::fs::PermissionsExt;
1300            if let Ok(mode) = entry.header().mode() {
1301                std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))?;
1302            }
1303        }
1304    }
1305    Ok(())
1306}
1307
1308// ---------------------------------------------------------------------
1309// Filesystem helpers
1310// ---------------------------------------------------------------------
1311
1312async fn read_and_parse_manifest(dir: &Path) -> PluginResult<PluginManifest> {
1313    let manifest_path = dir.join("plugin.json");
1314    let raw = tokio::fs::read_to_string(&manifest_path)
1315        .await
1316        .map_err(|_| {
1317            PluginError::InvalidManifest(format!(
1318                "no plugin.json found at '{}'",
1319                manifest_path.display()
1320            ))
1321        })?;
1322    PluginManifest::parse_str(&raw)
1323}
1324
1325/// If `dir` has no `plugin.json` of its own but contains EXACTLY one
1326/// subdirectory, move that subdirectory's contents up into `dir` (the common
1327/// `tar czf bundle.tar.gz plugin-name/`-style archive convention). A no-op if
1328/// `plugin.json` is already present, or if the shape doesn't match (multiple
1329/// top-level entries, or a single top-level entry that isn't a directory) —
1330/// in either case, [`read_and_parse_manifest`] will simply fail to find
1331/// `plugin.json` afterwards with a clear error.
1332async fn flatten_if_single_subdir(dir: &Path) -> PluginResult<()> {
1333    if tokio::fs::try_exists(dir.join("plugin.json"))
1334        .await
1335        .unwrap_or(false)
1336    {
1337        return Ok(());
1338    }
1339
1340    let mut entries = tokio::fs::read_dir(dir).await?;
1341    let mut only_entry: Option<PathBuf> = None;
1342    let mut count = 0usize;
1343    while let Some(entry) = entries.next_entry().await? {
1344        count += 1;
1345        if count > 1 {
1346            return Ok(());
1347        }
1348        only_entry = Some(entry.path());
1349    }
1350    let Some(candidate) = only_entry else {
1351        return Ok(());
1352    };
1353    // `symlink_metadata` (does NOT follow links), not `metadata`: defense in
1354    // depth so a symlink-to-a-real-dir can never pass `is_dir()` here and get
1355    // its real children renamed out. Extraction already rejects link entries
1356    // (see `extract_targz_sync`) and `copy_dir_recursive` skips symlinks, so
1357    // in practice `candidate` is always a real dir/file — but never follow a
1358    // link when deciding whether to descend into and move a directory's
1359    // contents.
1360    if !tokio::fs::symlink_metadata(&candidate).await?.is_dir() {
1361        return Ok(());
1362    }
1363
1364    // Move `candidate`'s children up into `dir`, then remove the now-empty
1365    // `candidate` directory.
1366    let mut children = tokio::fs::read_dir(&candidate).await?;
1367    while let Some(child) = children.next_entry().await? {
1368        let dest = dir.join(child.file_name());
1369        tokio::fs::rename(child.path(), dest).await?;
1370    }
1371    tokio::fs::remove_dir(&candidate).await?;
1372    Ok(())
1373}
1374
1375/// Recursively copy `source` into `dest` (creating `dest`).
1376fn copy_dir_recursive<'a>(
1377    source: &'a Path,
1378    dest: &'a Path,
1379) -> std::pin::Pin<Box<dyn std::future::Future<Output = PluginResult<()>> + Send + 'a>> {
1380    Box::pin(async move {
1381        tokio::fs::create_dir_all(dest).await?;
1382        let mut entries = tokio::fs::read_dir(source).await?;
1383        while let Some(entry) = entries.next_entry().await? {
1384            let file_type = entry.file_type().await?;
1385            let dest_path = dest.join(entry.file_name());
1386            if file_type.is_dir() {
1387                copy_dir_recursive(&entry.path(), &dest_path).await?;
1388            } else if file_type.is_file() {
1389                tokio::fs::copy(entry.path(), &dest_path).await?;
1390            }
1391            // Symlinks are intentionally skipped — a plugin bundle has no
1392            // legitimate reason to ship one, and following it could escape
1393            // the source directory.
1394        }
1395        Ok(())
1396    })
1397}
1398
1399#[cfg(test)]
1400mod tests;