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