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. [`prepare_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). The server-owned transaction seam then holds
115//! the plugin-operation lock while auditing global ownership; only an accepted
116//! [`PreparedPlugin`] is activated, after which the OLD `plugin_dir` is moved
117//! aside to a `.backup-*` directory (not deleted) and the candidate is renamed
118//! into place. The private staged transaction carries exact directory
119//! identities for the candidate and backup. On install failure it quarantines
120//! the live entry, and restores only an identity-verified backup with
121//! NOREPLACE. An ambiguous destination, candidate, or backup is preserved and
122//! requires manual recovery. Once an upgrade has stopped an old service, any
123//! later failure deliberately leaves it stopped for an operator to reconcile;
124//! source recovery never starts executable code automatically. Production
125//! exposes only [`install_server_plugin_from_source`];
126//! low-level staging helpers exist under `cfg(test)` and cannot bypass the
127//! server's ownership preflight or operation lock.
128//!
129//! Residual gap (documented, not solved here): the plugin_dir swap itself and
130//! `install()`'s own capability-registration rollback (see
131//! `crate::plugin_installer`'s module docs) are two separate best-effort
132//! steps, not one atomic transaction. If the process crashes between the
133//! swap and `install()` returning, a retry is still safe (staging always
134//! starts from a fresh scratch dir; a leftover `.backup-*`/`.staging-*` dir
135//! is inert and can be swept by an operator or a future cleanup pass) but is
136//! not automatic today.
137//!
138//! # Known follow-ups (deferred — tracked here, not fixed on this branch)
139//!
140//! - **URL content-bundle integrity pin: IMPLEMENTED (secure by default).**
141//!   Previously only the per-platform BINARY artifact was sha256-pinned
142//!   (`PluginArtifact.sha256`, verified in [`fetch_and_place_artifact`]),
143//!   while the `plugin.json` / content archive fetched by
144//!   [`fetch_manifest_bundle`] was trusted on HTTPS alone — a MITM or a
145//!   compromised host could serve a tampered bundle, and since the binary's
146//!   sha256 is DECLARED INSIDE that same untrusted manifest, tampering the
147//!   bundle could rewrite the artifact hash too (the trust chain was
148//!   circular). Fixed: [`PluginSourceInput::Url`] now carries a `sha256`
149//!   (the expected hash of the downloaded bundle) and an `allow_unverified`
150//!   opt-out. [`fetch_manifest_bundle`] verifies the bundle's actual sha256
151//!   against it BEFORE any extraction/parsing on a mismatch
152//!   ([`PluginError::BundleVerificationFailed`]); with neither a `sha256`
153//!   nor `allow_unverified: true`, the fetch is refused up front — before
154//!   the URL is ever requested — with [`PluginError::ChecksumRequired`]. A
155//!   URL install can therefore no longer just download-and-trust any
156//!   tar.gz. The verified bundle sha256 (not the binary artifact's) is what
157//!   `PluginSource::Url.sha256` records for provenance/audit.
158//! - **Source-TRUST layer: IMPLEMENTED** (host allowlist + ed25519 publisher
159//!   signature — see the module-level "three trust layers" summary above and
160//!   [`fetch_manifest_bundle`]). A sha256 pin alone only proves "this is the
161//!   bytes the installer expected", not "an entity I trust produced them" —
162//!   and worse, a checksum pasted from the SAME page as a malicious URL is
163//!   circular, proving nothing about the source. `bamboo_config::PluginTrustConfig`
164//!   (`trusted_hosts` + `trusted_keys`, both user-editable in `config.json`)
165//!   closes that: a URL install now also needs an operator-trusted host and
166//!   (absent `allow_unsigned`) a bundle signature verifying against a trusted
167//!   key. Still deferred:
168//!   - **SSRF guard**, described next.
169//! - **No SSRF guard on URL fetch.** [`download_bytes`] will fetch any URL,
170//!   including `http://169.254.169.254/...` (cloud metadata) or private-range
171//!   / loopback addresses. In a hosted/multi-tenant deployment a plugin-install
172//!   URL is an SSRF vector. A private-IP / metadata-endpoint blocklist (or an
173//!   allowlist of plugin registries) is a threat-model call for the deploy
174//!   layer; noted here so it isn't forgotten.
175//! - **`prompt-presets.json`'s `save_store` is non-atomic** (`fs::write` in
176//!   place, pre-existing behaviour shared with the HTTP prompt-preset
177//!   handlers): a crash mid-write can truncate `prompt-presets.json`. A
178//!   write-to-temp-then-rename would make it atomic, matching what
179//!   `bamboo_plugin::registry::InstalledPlugins::save` (`installed.json`) now
180//!   does; deferred here as a change to a shared, pre-existing storage
181//!   helper rather than this branch's new code.
182//!
183//! Production install/update handlers retain one `PLUGIN_OP_LOCK` guard across
184//! ownership preflight, service shutdown, activation, install, and rollback,
185//! so shared server state has no preflight-to-swap race.
186
187use std::path::{Path, PathBuf};
188use std::sync::OnceLock;
189
190use bamboo_config::PluginTrustConfig;
191use bamboo_plugin::manifest::Platform;
192#[cfg(test)]
193use bamboo_plugin::PluginInstaller;
194use bamboo_plugin::{
195    EventSinkPermissionGrants, InstallDisposition, InstalledPlugin, PluginError, PluginManifest,
196    PluginResult, PluginSource,
197};
198use ed25519_dalek::Verifier;
199
200use crate::plugin_installer::ServerPluginInstaller;
201use crate::tool_event_policy::{resolve_event_sink_grants, EventSinkGrantRequest};
202
203/// What the caller pointed the installer at.
204#[derive(Debug, Clone)]
205pub enum PluginSourceInput {
206    /// A local directory containing `plugin.json` at its root.
207    LocalDir(PathBuf),
208    /// A local `.zip` / `.tar.gz` / `.tgz` archive containing `plugin.json`
209    /// (at its root, or under a single top-level directory).
210    LocalArchive(PathBuf),
211    /// A URL to either a bare `plugin.json` or an archive containing one
212    /// (same root-or-single-subdir rule as `LocalArchive`). Three trust
213    /// layers, all enforced in [`fetch_manifest_bundle`] (see the module
214    /// docs' "three trust layers" summary):
215    ///
216    /// - `allow_untrusted_host`: opt out of the host allowlist
217    ///   (`bamboo_config::PluginTrustConfig::trusted_hosts`) — see
218    ///   [`PluginError::UntrustedHost`].
219    /// - `allow_unsigned`: opt out of requiring the bundle's `.sig` to verify
220    ///   against a trusted key — see [`PluginError::UnsignedOrUntrustedSignature`].
221    /// - `sha256`/`allow_unverified`: the checksum layer, unchanged from
222    ///   before EXCEPT a verified signature now also satisfies it (see
223    ///   [`fetch_manifest_bundle`]) — see [`PluginError::ChecksumRequired`].
224    ///
225    /// Plus `insecure`: the convenience AGGREGATE opt-out over all three
226    /// above (equivalent to setting `allow_untrusted_host`, `allow_unsigned`
227    /// AND `allow_unverified` together for THIS install) — see the module
228    /// docs' "`--insecure` / `plugin_trust.enforcement`" section. A supplied
229    /// `sha256` is still verified even when `insecure` is set (`insecure`
230    /// only turns checks OFF; it never turns a check the caller opted INTO
231    /// off too).
232    Url {
233        url: String,
234        sha256: Option<String>,
235        allow_unverified: bool,
236        allow_untrusted_host: bool,
237        allow_unsigned: bool,
238        insecure: bool,
239    },
240}
241
242/// A fully downloaded/copied, extracted, and validated plugin bundle that is
243/// still isolated under a UUID staging directory. The private server
244/// transaction inspects its manifest and runs shared ownership preflight
245/// before activation swaps any existing `plugins/<id>` directory.
246#[derive(Debug)]
247struct PreparedPlugin {
248    manifest: PluginManifest,
249    prepared_dir: PathBuf,
250    plugin_dir: PathBuf,
251    source: PluginSource,
252    candidate_identity: BundleIdentity,
253    // Keep the directory open for the whole transaction so an unlinked
254    // candidate's device/inode pair cannot be recycled and mistaken for a
255    // replacement directory before activation or rollback finishes.
256    _candidate_handle: std::fs::File,
257}
258
259#[derive(Clone, Copy, Debug, Eq, PartialEq)]
260struct BundleIdentity {
261    volume: u64,
262    file_id: [u8; 16],
263}
264
265#[derive(Debug)]
266struct BundleSnapshot {
267    path: PathBuf,
268    identity: BundleIdentity,
269    // The open handle pins the directory identity across sibling renames.
270    // Numeric identity alone is insufficient because filesystems may reuse
271    // an inode/file index after deletion.
272    _handle: std::fs::File,
273}
274
275#[derive(Debug)]
276enum BundleRecovery {
277    /// The on-disk transaction has been reconciled to a known state. This is
278    /// only a filesystem statement; it never authorizes starting code.
279    Reconciled,
280    /// At least one path is ambiguous. Every object is preserved until an
281    /// operator reconciles the paths.
282    ManualRecoveryRequired(String),
283}
284
285impl BundleRecovery {
286    fn is_reconciled(&self) -> bool {
287        matches!(self, Self::Reconciled)
288    }
289
290    fn wrap_error(self, error: PluginError) -> PluginError {
291        match self {
292            Self::Reconciled => error,
293            Self::ManualRecoveryRequired(detail) => PluginError::Registration(format!(
294                "{error}; manual bundle recovery is required: {detail}"
295            )),
296        }
297    }
298}
299
300#[derive(Debug)]
301struct BundleTransactionFailure {
302    error: PluginError,
303    recovery: BundleRecovery,
304}
305
306impl BundleTransactionFailure {
307    fn into_plugin_error(self) -> PluginError {
308        self.recovery.wrap_error(self.error)
309    }
310}
311
312#[cfg(unix)]
313fn capture_bundle_directory(path: &Path) -> std::io::Result<(std::fs::File, BundleIdentity)> {
314    use std::os::unix::fs::MetadataExt;
315
316    let handle: std::fs::File = rustix::fs::open(
317        path,
318        rustix::fs::OFlags::RDONLY
319            | rustix::fs::OFlags::DIRECTORY
320            | rustix::fs::OFlags::NOFOLLOW
321            | rustix::fs::OFlags::CLOEXEC,
322        rustix::fs::Mode::empty(),
323    )
324    .map_err(std::io::Error::from)?
325    .into();
326    let metadata = handle.metadata()?;
327    if !metadata.is_dir() {
328        return Err(std::io::Error::new(
329            std::io::ErrorKind::InvalidData,
330            "plugin bundle path must name a real directory",
331        ));
332    }
333    let mut file_id = [0; 16];
334    file_id[..8].copy_from_slice(&metadata.ino().to_ne_bytes());
335    let identity = BundleIdentity {
336        volume: metadata.dev(),
337        file_id,
338    };
339    Ok((handle, identity))
340}
341
342#[cfg(windows)]
343fn capture_bundle_directory(path: &Path) -> std::io::Result<(std::fs::File, BundleIdentity)> {
344    use std::mem::{size_of, MaybeUninit};
345    use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
346    use std::os::windows::io::AsRawHandle;
347    use windows_sys::Win32::Storage::FileSystem::{
348        FileIdInfo, GetFileInformationByHandleEx, FILE_ATTRIBUTE_REPARSE_POINT,
349        FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, FILE_SHARE_DELETE,
350        FILE_SHARE_READ, FILE_SHARE_WRITE,
351    };
352
353    let file = std::fs::OpenOptions::new()
354        .read(true)
355        .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
356        .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
357        .open(path)?;
358    let metadata = file.metadata()?;
359    if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
360        return Err(std::io::Error::new(
361            std::io::ErrorKind::InvalidData,
362            "plugin bundle path must name a real directory, not a reparse point",
363        ));
364    }
365    let mut identity = MaybeUninit::<FILE_ID_INFO>::zeroed();
366    // SAFETY: `file` remains open for the call, `identity` points to writable
367    // storage of exactly the advertised size, and FileIdInfo initializes a
368    // FILE_ID_INFO on success.
369    let succeeded = unsafe {
370        GetFileInformationByHandleEx(
371            file.as_raw_handle(),
372            FileIdInfo,
373            identity.as_mut_ptr().cast(),
374            size_of::<FILE_ID_INFO>() as u32,
375        )
376    };
377    if succeeded == 0 {
378        return Err(std::io::Error::last_os_error());
379    }
380    // SAFETY: a nonzero return from GetFileInformationByHandleEx means the
381    // FILE_ID_INFO output buffer was initialized.
382    let identity = unsafe { identity.assume_init() };
383    let identity = BundleIdentity {
384        volume: identity.VolumeSerialNumber,
385        file_id: identity.FileId.Identifier,
386    };
387    Ok((file, identity))
388}
389
390#[cfg(not(any(unix, windows)))]
391fn capture_bundle_directory(_path: &Path) -> std::io::Result<(std::fs::File, BundleIdentity)> {
392    Err(std::io::Error::new(
393        std::io::ErrorKind::Unsupported,
394        "identity-bound plugin activation is unavailable on this platform",
395    ))
396}
397
398fn bundle_directory_identity(path: &Path) -> std::io::Result<BundleIdentity> {
399    capture_bundle_directory(path).map(|(_handle, identity)| identity)
400}
401
402fn capture_optional_bundle_snapshot(path: &Path) -> std::io::Result<Option<BundleSnapshot>> {
403    match capture_bundle_directory(path) {
404        Ok((handle, identity)) => Ok(Some(BundleSnapshot {
405            path: path.to_path_buf(),
406            identity,
407            _handle: handle,
408        })),
409        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
410        Err(error) => Err(error),
411    }
412}
413
414/// Move an identity-bound transaction entry to a fresh sibling name and
415/// retain it. If the source was replaced before the rename, put the unknown
416/// entry back with NOREPLACE when possible. No recursive deletion occurs.
417fn retain_identity_bound_directory(
418    path: &Path,
419    expected: BundleIdentity,
420    prefix: &str,
421    context: &str,
422) {
423    let Some(parent) = path.parent() else {
424        tracing::warn!(path = %path.display(), %context, "transaction entry has no parent; retaining it in place");
425        return;
426    };
427    let retained = parent.join(format!(".{prefix}-{}", uuid::Uuid::new_v4()));
428    match rename_noreplace(path, &retained) {
429        Ok(()) => match bundle_directory_identity(&retained) {
430            Ok(identity) if identity == expected => tracing::warn!(
431                retained = %retained.display(),
432                %context,
433                "identity-verified transaction entry retained for operator cleanup"
434            ),
435            observed => {
436                let put_back = rename_noreplace(&retained, path);
437                tracing::warn!(
438                    original = %path.display(),
439                    retained = %retained.display(),
440                    ?observed,
441                    ?put_back,
442                    %context,
443                    "transaction entry changed identity; unknown replacement was preserved without deletion"
444                );
445            }
446        },
447        Err(error) if error.kind() == std::io::ErrorKind::NotFound => tracing::warn!(
448            path = %path.display(),
449            %context,
450            "identity-bound transaction entry disappeared before it could be retained"
451        ),
452        Err(error) => tracing::warn!(
453            path = %path.display(),
454            %error,
455            %context,
456            "failed to quarantine transaction entry; retaining it in place"
457        ),
458    }
459}
460
461/// Retain a staging directory for which no stable identity was captured.
462/// Renaming an entry is non-recursive and does not follow a symlink/reparse
463/// point; any failure simply leaves the entry at its original private name.
464fn retain_unverified_staging(path: &Path, context: &str) {
465    let Some(parent) = path.parent() else {
466        tracing::warn!(path = %path.display(), %context, "unverified staging entry has no parent; retaining it in place");
467        return;
468    };
469    let retained = parent.join(format!(".rejected-staging-{}", uuid::Uuid::new_v4()));
470    match rename_noreplace(path, &retained) {
471        Ok(()) => tracing::warn!(
472            retained = %retained.display(),
473            %context,
474            "rejected staging directory retained for operator cleanup"
475        ),
476        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
477        Err(error) => tracing::warn!(
478            path = %path.display(),
479            %error,
480            %context,
481            "failed to quarantine rejected staging directory; retaining it in place"
482        ),
483    }
484}
485
486fn restore_verified_backup(backup: &BundleSnapshot, plugin_dir: &Path) -> BundleRecovery {
487    match bundle_directory_identity(&backup.path) {
488        Ok(identity) if identity == backup.identity => {}
489        Ok(_) => {
490            return BundleRecovery::ManualRecoveryRequired(format!(
491                "the backup at '{}' changed identity and was not moved",
492                backup.path.display()
493            ));
494        }
495        Err(error) => {
496            return BundleRecovery::ManualRecoveryRequired(format!(
497                "the backup at '{}' could not be identity-verified and was not moved: {error}",
498                backup.path.display()
499            ));
500        }
501    }
502    if let Err(error) = rename_noreplace(&backup.path, plugin_dir) {
503        return BundleRecovery::ManualRecoveryRequired(format!(
504            "the previous bundle remains at '{}' because '{}' could not be restored without replacement: {error}",
505            backup.path.display(),
506            plugin_dir.display()
507        ));
508    }
509    match bundle_directory_identity(plugin_dir) {
510        Ok(identity) if identity == backup.identity => BundleRecovery::Reconciled,
511        Ok(_) => BundleRecovery::ManualRecoveryRequired(format!(
512            "the restored destination '{}' does not have the previous bundle identity",
513            plugin_dir.display()
514        )),
515        Err(error) => BundleRecovery::ManualRecoveryRequired(format!(
516            "the restored destination '{}' could not be identity-verified: {error}",
517            plugin_dir.display()
518        )),
519    }
520}
521
522impl PreparedPlugin {
523    fn retain_candidate(&self, context: &str) {
524        retain_identity_bound_directory(
525            &self.prepared_dir,
526            self.candidate_identity,
527            &format!("candidate-{}", self.manifest.id),
528            context,
529        );
530    }
531
532    fn capture_expected_live(&self) -> std::io::Result<Option<BundleSnapshot>> {
533        capture_optional_bundle_snapshot(&self.plugin_dir)
534    }
535
536    /// Atomically make this candidate the plugin's fixed on-disk bundle,
537    /// retaining the previous bundle for commit/rollback. Shared ownership
538    /// preflight must happen before this test-only convenience call. The
539    /// server path captures the expected live snapshot before stopping any
540    /// service and passes it directly to [`Self::activate_inner`].
541    #[cfg(test)]
542    async fn activate(self) -> Result<StagedPlugin, BundleTransactionFailure> {
543        let expected_live = match self.capture_expected_live() {
544            Ok(snapshot) => snapshot,
545            Err(error) => {
546                self.retain_candidate("live snapshot capture failed before test activation");
547                return Err(BundleTransactionFailure {
548                    error: PluginError::Io(error),
549                    recovery: BundleRecovery::ManualRecoveryRequired(format!(
550                        "the live destination '{}' could not be captured before activation",
551                        self.plugin_dir.display()
552                    )),
553                });
554            }
555        };
556        self.activate_inner(expected_live, ActivationFault::None)
557            .await
558    }
559
560    async fn activate_inner(
561        self,
562        expected_live: Option<BundleSnapshot>,
563        fault: ActivationFault,
564    ) -> Result<StagedPlugin, BundleTransactionFailure> {
565        match bundle_directory_identity(&self.prepared_dir) {
566            Ok(identity) if identity == self.candidate_identity => {}
567            observed => {
568                self.retain_candidate("prepared candidate changed identity before activation");
569                return Err(BundleTransactionFailure {
570                    error: PluginError::Registration(format!(
571                        "prepared plugin '{}' changed identity before activation ({observed:?})",
572                        self.manifest.id
573                    )),
574                    recovery: BundleRecovery::ManualRecoveryRequired(
575                        "the expected candidate and its replacement were preserved".to_string(),
576                    ),
577                });
578            }
579        }
580
581        let backup = match expected_live {
582            Some(mut previous) => {
583                if previous.path != self.plugin_dir {
584                    self.retain_candidate("expected live snapshot path was inconsistent");
585                    return Err(BundleTransactionFailure {
586                        error: PluginError::Registration(
587                            "expected live snapshot did not name this plugin destination"
588                                .to_string(),
589                        ),
590                        recovery: BundleRecovery::ManualRecoveryRequired(format!(
591                            "the candidate at '{}' was retained without touching either live path",
592                            self.prepared_dir.display()
593                        )),
594                    });
595                }
596                match bundle_directory_identity(&self.plugin_dir) {
597                    Ok(identity) if identity == previous.identity => {}
598                    observed => {
599                        self.retain_candidate(
600                            "live bundle changed after its pre-stop snapshot was captured",
601                        );
602                        return Err(BundleTransactionFailure {
603                            error: PluginError::Registration(format!(
604                                "live plugin '{}' no longer matches the exact pre-stop snapshot ({observed:?})",
605                                self.manifest.id
606                            )),
607                            recovery: BundleRecovery::ManualRecoveryRequired(format!(
608                                "the unexpected destination '{}' was left untouched",
609                                self.plugin_dir.display()
610                            )),
611                        });
612                    }
613                }
614                let Some(root) = self.plugin_dir.parent() else {
615                    self.retain_candidate("plugin destination had no parent");
616                    return Err(BundleTransactionFailure {
617                        error: PluginError::InvalidManifest(
618                            "plugin directory has no parent".to_string(),
619                        ),
620                        recovery: BundleRecovery::ManualRecoveryRequired(
621                            "the previous bundle path had no parent".to_string(),
622                        ),
623                    });
624                };
625                let backup = root.join(format!(
626                    ".backup-{}-{}",
627                    self.manifest.id,
628                    uuid::Uuid::new_v4()
629                ));
630                if let Err(error) = rename_noreplace(&self.plugin_dir, &backup) {
631                    self.retain_candidate("previous bundle backup rename failed");
632                    let recovery = match bundle_directory_identity(&self.plugin_dir) {
633                        Ok(identity) if identity == previous.identity => BundleRecovery::Reconciled,
634                        Ok(_) => BundleRecovery::ManualRecoveryRequired(format!(
635                            "the destination '{}' changed identity while the backup rename failed",
636                            self.plugin_dir.display()
637                        )),
638                        Err(verify_error) => BundleRecovery::ManualRecoveryRequired(format!(
639                            "the backup rename failed and the previous bundle at '{}' could not be reverified: {verify_error}",
640                            self.plugin_dir.display()
641                        )),
642                    };
643                    return Err(BundleTransactionFailure {
644                        error: PluginError::Io(error),
645                        recovery,
646                    });
647                }
648                match bundle_directory_identity(&backup) {
649                    Ok(identity) if identity == previous.identity => {}
650                    Ok(_) => {
651                        self.retain_candidate("moved previous bundle changed identity");
652                        return Err(BundleTransactionFailure {
653                            error: PluginError::Registration(format!(
654                                "the previous plugin bundle changed identity while moving to '{}'",
655                                backup.display()
656                            )),
657                            recovery: BundleRecovery::ManualRecoveryRequired(format!(
658                                "the ambiguous backup was preserved at '{}'",
659                                backup.display()
660                            )),
661                        });
662                    }
663                    Err(error) => {
664                        self.retain_candidate("moved previous bundle could not be verified");
665                        return Err(BundleTransactionFailure {
666                            error: PluginError::Io(error),
667                            recovery: BundleRecovery::ManualRecoveryRequired(format!(
668                                "the unverified backup was preserved at '{}'",
669                                backup.display()
670                            )),
671                        });
672                    }
673                }
674                previous.path = backup;
675                Some(previous)
676            }
677            None => match std::fs::symlink_metadata(&self.plugin_dir) {
678                Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
679                Ok(_) => {
680                    self.retain_candidate("unexpected fresh-install destination appeared");
681                    return Err(BundleTransactionFailure {
682                        error: PluginError::Registration(format!(
683                            "plugin destination '{}' appeared after the no-live snapshot was captured",
684                            self.plugin_dir.display()
685                        )),
686                        recovery: BundleRecovery::ManualRecoveryRequired(
687                            "the unexpected destination was left untouched".to_string(),
688                        ),
689                    });
690                }
691                Err(error) => {
692                    self.retain_candidate("fresh-install destination could not be inspected");
693                    return Err(BundleTransactionFailure {
694                        error: PluginError::Io(error),
695                        recovery: BundleRecovery::ManualRecoveryRequired(format!(
696                            "the destination '{}' could not be inspected",
697                            self.plugin_dir.display()
698                        )),
699                    });
700                }
701            },
702        };
703
704        let rename_result = fault.install_destination(&self.plugin_dir).and_then(|()| {
705            if fault.fail_candidate_rename() {
706                Err(std::io::Error::other(
707                    "injected prepared-plugin activation rename failure",
708                ))
709            } else {
710                rename_noreplace(&self.prepared_dir, &self.plugin_dir)
711            }
712        });
713        if let Err(rename_error) = rename_result {
714            // Both paths are siblings below one plugins root, so EXDEV is an
715            // invariant violation, not a reason to merge-copy into a live
716            // destination. Retain the private UUID candidate without a
717            // path-based recursive delete. If an old bundle was backed up,
718            // restore it with an atomic NOREPLACE rename; a race-created
719            // destination is never overwritten or deleted, and a failed
720            // restore deliberately leaves the backup intact for recovery.
721            self.retain_candidate("candidate publication failed");
722            let recovery = match &backup {
723                Some(backup) => restore_verified_backup(backup, &self.plugin_dir),
724                None => match std::fs::symlink_metadata(&self.plugin_dir) {
725                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
726                        BundleRecovery::Reconciled
727                    }
728                    Ok(_) => BundleRecovery::ManualRecoveryRequired(format!(
729                        "an unexpected destination remains at '{}' and there was no previous bundle",
730                        self.plugin_dir.display()
731                    )),
732                    Err(error) => BundleRecovery::ManualRecoveryRequired(format!(
733                        "the destination '{}' could not be inspected after publication failed: {error}",
734                        self.plugin_dir.display()
735                    )),
736                },
737            };
738            return Err(BundleTransactionFailure {
739                error: PluginError::Registration(format!(
740                    "failed to atomically activate prepared plugin '{}' with a no-replace rename: {rename_error}",
741                    self.manifest.id
742                )),
743                recovery,
744            });
745        }
746
747        match bundle_directory_identity(&self.plugin_dir) {
748            Ok(identity) if identity == self.candidate_identity => {}
749            Ok(_) => {
750                return Err(BundleTransactionFailure {
751                    error: PluginError::Registration(format!(
752                        "activated plugin '{}' changed identity during publication",
753                        self.manifest.id
754                    )),
755                    recovery: BundleRecovery::ManualRecoveryRequired(format!(
756                        "the live destination '{}' and backup were preserved",
757                        self.plugin_dir.display()
758                    )),
759                });
760            }
761            Err(error) => {
762                return Err(BundleTransactionFailure {
763                    error: PluginError::Io(error),
764                    recovery: BundleRecovery::ManualRecoveryRequired(format!(
765                        "the activated destination '{}' could not be identity-verified; its backup was preserved",
766                        self.plugin_dir.display()
767                    )),
768                });
769            }
770        }
771
772        Ok(StagedPlugin {
773            manifest: self.manifest,
774            plugin_dir: self.plugin_dir,
775            source: self.source,
776            candidate_identity: self.candidate_identity,
777            _candidate_handle: self._candidate_handle,
778            backup,
779        })
780    }
781
782    /// Quarantine and retain an unactivated candidate after path-id or
783    /// ownership preflight refuses it. The live plugin bundle is untouched;
784    /// path-based recursive deletion is deliberately avoided.
785    async fn discard(self) {
786        self.retain_candidate("prepared plugin candidate was discarded before activation");
787    }
788
789    #[cfg(test)]
790    async fn activate_with_fault(
791        self,
792        fault: ActivationFault,
793    ) -> Result<StagedPlugin, BundleTransactionFailure> {
794        let expected_live = match self.capture_expected_live() {
795            Ok(snapshot) => snapshot,
796            Err(error) => {
797                self.retain_candidate("live snapshot capture failed before faulted activation");
798                return Err(BundleTransactionFailure {
799                    error: PluginError::Io(error),
800                    recovery: BundleRecovery::ManualRecoveryRequired(format!(
801                        "the live destination '{}' could not be captured before activation",
802                        self.plugin_dir.display()
803                    )),
804                });
805            }
806        };
807        self.activate_inner(expected_live, fault).await
808    }
809}
810
811#[derive(Debug)]
812enum ActivationFault {
813    None,
814    #[cfg(test)]
815    FailCandidateRename,
816    #[cfg(test)]
817    CreateDestinationDirectory,
818    #[cfg(all(test, unix))]
819    CreateDestinationSymlink(PathBuf),
820}
821
822impl ActivationFault {
823    fn fail_candidate_rename(&self) -> bool {
824        #[cfg(test)]
825        {
826            matches!(self, Self::FailCandidateRename)
827        }
828        #[cfg(not(test))]
829        {
830            false
831        }
832    }
833
834    fn install_destination(&self, _destination: &Path) -> std::io::Result<()> {
835        match self {
836            Self::None => Ok(()),
837            #[cfg(test)]
838            Self::FailCandidateRename => Ok(()),
839            #[cfg(test)]
840            Self::CreateDestinationDirectory => {
841                std::fs::create_dir(_destination)?;
842                std::fs::write(_destination.join("RACE_MARKER"), b"race-owned")?;
843                Ok(())
844            }
845            #[cfg(all(test, unix))]
846            Self::CreateDestinationSymlink(target) => {
847                std::os::unix::fs::symlink(target, _destination)?;
848                Ok(())
849            }
850        }
851    }
852}
853
854/// Atomically rename one sibling entry without replacing any destination that
855/// appeared after preflight. Prepared-bundle publication and its immediate
856/// backup restoration both use this primitive, so neither can overwrite a
857/// race-created directory, file, symlink, or Windows reparse point.
858#[cfg(any(
859    target_os = "linux",
860    target_os = "android",
861    target_vendor = "apple",
862    target_os = "redox"
863))]
864fn rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> {
865    use std::os::fd::AsFd;
866
867    let source_parent = source
868        .parent()
869        .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
870    let destination_parent = destination
871        .parent()
872        .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
873    if source_parent != destination_parent {
874        return Err(std::io::Error::new(
875            std::io::ErrorKind::InvalidInput,
876            "prepared plugin activation paths must be siblings",
877        ));
878    }
879    let source_name = source
880        .file_name()
881        .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
882    let destination_name = destination
883        .file_name()
884        .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
885    let parent = std::fs::File::open(source_parent)?;
886    rustix::fs::renameat_with(
887        parent.as_fd(),
888        source_name,
889        parent.as_fd(),
890        destination_name,
891        rustix::fs::RenameFlags::NOREPLACE,
892    )
893    .map_err(std::io::Error::from)
894}
895
896#[cfg(windows)]
897fn rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> {
898    use std::os::windows::ffi::OsStrExt;
899    use windows_sys::Win32::Storage::FileSystem::MoveFileExW;
900
901    let source_parent = source
902        .parent()
903        .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
904    let destination_parent = destination
905        .parent()
906        .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
907    if source_parent != destination_parent {
908        return Err(std::io::Error::new(
909            std::io::ErrorKind::InvalidInput,
910            "prepared plugin activation paths must be siblings",
911        ));
912    }
913
914    fn nul_terminated(path: &Path) -> std::io::Result<Vec<u16>> {
915        let mut wide = path.as_os_str().encode_wide().collect::<Vec<_>>();
916        if wide.contains(&0) {
917            return Err(std::io::Error::new(
918                std::io::ErrorKind::InvalidInput,
919                "plugin activation path contains an interior NUL",
920            ));
921        }
922        wide.push(0);
923        Ok(wide)
924    }
925
926    let source = nul_terminated(source)?;
927    let destination = nul_terminated(destination)?;
928    // No MOVEFILE_REPLACE_EXISTING flag: a destination that appeared after
929    // preflight, including a reparse point, makes this atomic rename fail.
930    let result = unsafe { MoveFileExW(source.as_ptr(), destination.as_ptr(), 0) };
931    if result == 0 {
932        Err(std::io::Error::last_os_error())
933    } else {
934        Ok(())
935    }
936}
937
938#[cfg(not(any(
939    windows,
940    target_os = "linux",
941    target_os = "android",
942    target_vendor = "apple",
943    target_os = "redox"
944)))]
945fn rename_noreplace(_source: &Path, _destination: &Path) -> std::io::Result<()> {
946    Err(std::io::Error::new(
947        std::io::ErrorKind::Unsupported,
948        "atomic no-replace plugin activation is unavailable on this platform",
949    ))
950}
951
952/// Private staged source transaction. Keeping both the type and every field
953/// private prevents callers from swapping a bundle without server ownership
954/// preflight or replacing its identity-bound paths after validation.
955#[derive(Debug)]
956struct StagedPlugin {
957    manifest: PluginManifest,
958    plugin_dir: PathBuf,
959    source: PluginSource,
960    candidate_identity: BundleIdentity,
961    _candidate_handle: std::fs::File,
962    backup: Option<BundleSnapshot>,
963}
964
965#[derive(Debug)]
966enum RollbackFault {
967    None,
968    #[cfg(test)]
969    ReplaceDestinationDirectory,
970}
971
972impl RollbackFault {
973    fn install_destination(&self, _plugin_dir: &Path) -> std::io::Result<()> {
974        match self {
975            Self::None => Ok(()),
976            #[cfg(test)]
977            Self::ReplaceDestinationDirectory => {
978                let parent = _plugin_dir.parent().ok_or_else(|| {
979                    std::io::Error::new(
980                        std::io::ErrorKind::InvalidInput,
981                        "plugin directory has no parent",
982                    )
983                })?;
984                let displaced = parent.join(format!(
985                    ".fault-displaced-candidate-{}",
986                    uuid::Uuid::new_v4()
987                ));
988                rename_noreplace(_plugin_dir, &displaced)?;
989                std::fs::create_dir(_plugin_dir)?;
990                std::fs::write(_plugin_dir.join("RACE_MARKER"), b"race-owned")
991            }
992        }
993    }
994}
995
996impl StagedPlugin {
997    /// Finalize a successful install. Post-commit backup cleanup is
998    /// best-effort housekeeping, not part of live-bundle selection: a backup
999    /// is moved to a private retirement path and retained. Recursive deletion
1000    /// is deliberately excluded from the identity-sensitive transaction.
1001    async fn commit(self) {
1002        let Some(backup) = self.backup else {
1003            return;
1004        };
1005        let Some(parent) = backup.path.parent() else {
1006            tracing::warn!(
1007                backup = %backup.path.display(),
1008                "committed plugin backup has no parent; leaving it for operator cleanup"
1009            );
1010            return;
1011        };
1012        let retired = parent.join(format!(
1013            ".retired-{}-{}",
1014            self.manifest.id,
1015            uuid::Uuid::new_v4()
1016        ));
1017        if let Err(error) = rename_noreplace(&backup.path, &retired) {
1018            tracing::warn!(
1019                %error,
1020                backup = %backup.path.display(),
1021                "failed to retire committed plugin backup; leaving it in place"
1022            );
1023            return;
1024        }
1025        match bundle_directory_identity(&retired) {
1026            Ok(identity) if identity == backup.identity => tracing::warn!(
1027                retired = %retired.display(),
1028                "committed plugin backup was retired and retained for operator cleanup"
1029            ),
1030            identity => {
1031                let restored = rename_noreplace(&retired, &backup.path);
1032                tracing::warn!(
1033                    retired = %retired.display(),
1034                    backup = %backup.path.display(),
1035                    observed = ?identity,
1036                    restore = ?restored,
1037                    "retired plugin backup identity was ambiguous; preserved without deletion"
1038                );
1039            }
1040        }
1041    }
1042
1043    /// Undo a failed install without deleting a path merely because it has
1044    /// the expected name. The live entry is atomically quarantined first. A
1045    /// verified candidate remains inert in quarantine after recovery; an
1046    /// unexpected entry is put back with NOREPLACE and the old backup remains
1047    /// preserved for manual recovery.
1048    #[cfg(test)]
1049    async fn rollback(self) -> BundleRecovery {
1050        self.rollback_inner(RollbackFault::None).await
1051    }
1052
1053    async fn rollback_inner(self, fault: RollbackFault) -> BundleRecovery {
1054        if let Err(error) = fault.install_destination(&self.plugin_dir) {
1055            return BundleRecovery::ManualRecoveryRequired(format!(
1056                "rollback fault setup failed without deleting any bundle path: {error}"
1057            ));
1058        }
1059
1060        let Some(parent) = self.plugin_dir.parent() else {
1061            return BundleRecovery::ManualRecoveryRequired(
1062                "the live plugin path has no parent".to_string(),
1063            );
1064        };
1065        let quarantine = parent.join(format!(
1066            ".rollback-{}-{}",
1067            self.manifest.id,
1068            uuid::Uuid::new_v4()
1069        ));
1070        match rename_noreplace(&self.plugin_dir, &quarantine) {
1071            Ok(()) => {}
1072            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1073                return match &self.backup {
1074                    Some(backup) => restore_verified_backup(backup, &self.plugin_dir),
1075                    None => BundleRecovery::Reconciled,
1076                };
1077            }
1078            Err(error) => {
1079                return BundleRecovery::ManualRecoveryRequired(format!(
1080                    "the live destination '{}' could not be quarantined without replacement and was left untouched: {error}",
1081                    self.plugin_dir.display()
1082                ));
1083            }
1084        }
1085
1086        match bundle_directory_identity(&quarantine) {
1087            Ok(identity) if identity == self.candidate_identity => {}
1088            observed => {
1089                let put_back = rename_noreplace(&quarantine, &self.plugin_dir);
1090                return BundleRecovery::ManualRecoveryRequired(format!(
1091                    "the live destination was not this transaction's candidate ({observed:?}); the unexpected object was preserved at '{}' (put-back result: {put_back:?}) and the previous backup was not moved",
1092                    if put_back.is_ok() {
1093                        self.plugin_dir.display()
1094                    } else {
1095                        quarantine.display()
1096                    }
1097                ));
1098            }
1099        }
1100
1101        let recovery = match &self.backup {
1102            Some(backup) => restore_verified_backup(backup, &self.plugin_dir),
1103            None => BundleRecovery::Reconciled,
1104        };
1105        if !recovery.is_reconciled() {
1106            // The known candidate and old backup are both retained. Deleting
1107            // either would make an already-ambiguous recovery irreversible.
1108            return recovery;
1109        }
1110
1111        // Keep the failed candidate quarantined. Even a second identity check
1112        // followed by path-based recursive deletion would leave a
1113        // check-to-delete race in which a watcher could replace this UUID
1114        // path. The old live bundle is already restored, but executable code
1115        // still remains stopped after a failed server-owned upgrade.
1116        tracing::warn!(
1117            quarantine = %quarantine.display(),
1118            "failed plugin candidate was quarantined after rollback and retained for operator cleanup"
1119        );
1120        recovery
1121    }
1122}
1123
1124/// Prepare a source completely in an isolated scratch directory without
1125/// swapping `plugins/<id>`. This is the production seam for callers that must
1126/// perform global provenance/ownership checks before any shared mutation.
1127async fn prepare_plugin_source(
1128    input: PluginSourceInput,
1129    plugins_root: &Path,
1130    trust: &PluginTrustConfig,
1131) -> PluginResult<PreparedPlugin> {
1132    prepare_plugin_source_inner(input, plugins_root, trust, MAX_DECOMPRESSED_BYTES).await
1133}
1134
1135/// Test-only low-level staging seam. Production callers cannot activate a
1136/// bundle outside [`install_server_plugin_from_source`]'s ownership preflight
1137/// and operation lock.
1138#[cfg(test)]
1139async fn stage_plugin_source(
1140    input: PluginSourceInput,
1141    plugins_root: &Path,
1142    trust: &PluginTrustConfig,
1143) -> PluginResult<StagedPlugin> {
1144    stage_plugin_source_inner(input, plugins_root, trust, MAX_DECOMPRESSED_BYTES).await
1145}
1146
1147/// Test-only seam for [`stage_plugin_source`] that lets a test inject a small
1148/// `max_decompressed_bytes` cap (the production cap, [`MAX_DECOMPRESSED_BYTES`],
1149/// is a generous 2 GiB — not practical to actually exceed in a unit test).
1150/// Exercises the exact same staging/swap machinery as the production path,
1151/// just with the archive-extraction ceiling parameterized.
1152#[cfg(test)]
1153async fn stage_plugin_source_with_decompressed_cap(
1154    input: PluginSourceInput,
1155    plugins_root: &Path,
1156    trust: &PluginTrustConfig,
1157    max_decompressed_bytes: u64,
1158) -> PluginResult<StagedPlugin> {
1159    stage_plugin_source_inner(input, plugins_root, trust, max_decompressed_bytes).await
1160}
1161
1162#[cfg(test)]
1163async fn stage_plugin_source_inner(
1164    input: PluginSourceInput,
1165    plugins_root: &Path,
1166    trust: &PluginTrustConfig,
1167    max_decompressed_bytes: u64,
1168) -> PluginResult<StagedPlugin> {
1169    prepare_plugin_source_inner(input, plugins_root, trust, max_decompressed_bytes)
1170        .await?
1171        .activate()
1172        .await
1173        .map_err(BundleTransactionFailure::into_plugin_error)
1174}
1175
1176async fn prepare_plugin_source_inner(
1177    input: PluginSourceInput,
1178    plugins_root: &Path,
1179    trust: &PluginTrustConfig,
1180    max_decompressed_bytes: u64,
1181) -> PluginResult<PreparedPlugin> {
1182    tokio::fs::create_dir_all(plugins_root).await?;
1183    let staging_dir = plugins_root.join(format!(".staging-{}", uuid::Uuid::new_v4()));
1184    tokio::fs::create_dir_all(&staging_dir).await?;
1185
1186    let staged = stage_into(&input, &staging_dir, trust, max_decompressed_bytes).await;
1187    let (manifest, source) = match staged {
1188        Ok(pair) => pair,
1189        Err(error) => {
1190            retain_unverified_staging(&staging_dir, "plugin source preparation failed");
1191            return Err(error);
1192        }
1193    };
1194
1195    if let Err(error) = manifest.validate() {
1196        retain_unverified_staging(&staging_dir, "prepared plugin manifest validation failed");
1197        return Err(error);
1198    }
1199
1200    let (candidate_handle, candidate_identity) = match capture_bundle_directory(&staging_dir) {
1201        Ok(snapshot) => snapshot,
1202        Err(error) => {
1203            retain_unverified_staging(&staging_dir, "prepared candidate identity capture failed");
1204            return Err(PluginError::Io(error));
1205        }
1206    };
1207    let plugin_dir = plugins_root.join(&manifest.id);
1208    Ok(PreparedPlugin {
1209        manifest,
1210        plugin_dir,
1211        prepared_dir: staging_dir,
1212        source,
1213        candidate_identity,
1214        _candidate_handle: candidate_handle,
1215    })
1216}
1217
1218/// Server-owned source transaction. This is the only public source-install
1219/// seam for [`ServerPluginInstaller`]: the same process-wide guard spans
1220/// provenance preflight, prior-service shutdown, live-bundle activation,
1221/// installer mutation, and commit/rollback. Once an upgrade stops services,
1222/// a subsequent failure leaves them stopped for explicit operator recovery.
1223///
1224/// `expected_plugin_id` binds an HTTP path (or another caller-owned identity)
1225/// before any shared mutation. Pass `None` when the source manifest owns the
1226/// identity, as in the manual server example.
1227pub async fn install_server_plugin_from_source(
1228    installer: &ServerPluginInstaller,
1229    input: PluginSourceInput,
1230    plugins_root: &Path,
1231    trust: &PluginTrustConfig,
1232    disposition: InstallDisposition,
1233    expected_plugin_id: Option<&str>,
1234) -> PluginResult<InstalledPlugin> {
1235    install_server_plugin_from_source_with_event_sink_grants(
1236        installer,
1237        input,
1238        plugins_root,
1239        trust,
1240        disposition,
1241        expected_plugin_id,
1242        None,
1243    )
1244    .await
1245}
1246
1247/// Source transaction with an optional complete per-sink host grant target.
1248/// Resolution happens against the prepared manifest and prior durable
1249/// provenance before service shutdown or bundle activation. The installer
1250/// receives the canonical map and validates it again under the same operation
1251/// guard before writing the Installing journal row.
1252pub async fn install_server_plugin_from_source_with_event_sink_grants(
1253    installer: &ServerPluginInstaller,
1254    input: PluginSourceInput,
1255    plugins_root: &Path,
1256    trust: &PluginTrustConfig,
1257    disposition: InstallDisposition,
1258    expected_plugin_id: Option<&str>,
1259    requested_grants: Option<&[EventSinkGrantRequest]>,
1260) -> PluginResult<InstalledPlugin> {
1261    install_server_plugin_from_source_inner(
1262        installer,
1263        input,
1264        plugins_root,
1265        trust,
1266        disposition,
1267        expected_plugin_id,
1268        requested_grants,
1269        ServerSourceFault::None,
1270    )
1271    .await
1272}
1273
1274#[derive(Debug)]
1275enum ServerSourceFault {
1276    None,
1277    #[cfg(test)]
1278    ActivationRenameFailure,
1279    #[cfg(test)]
1280    ActivationDestinationDirectory,
1281    #[cfg(test)]
1282    ReplaceLiveAfterStop,
1283    #[cfg(test)]
1284    RollbackDestinationDirectory,
1285    #[cfg(test)]
1286    FinalProvenanceCommitFailure,
1287}
1288
1289impl ServerSourceFault {
1290    fn activation_fault(&self) -> ActivationFault {
1291        match self {
1292            Self::None => ActivationFault::None,
1293            #[cfg(test)]
1294            Self::ActivationRenameFailure => ActivationFault::FailCandidateRename,
1295            #[cfg(test)]
1296            Self::ActivationDestinationDirectory => ActivationFault::CreateDestinationDirectory,
1297            #[cfg(test)]
1298            Self::ReplaceLiveAfterStop => ActivationFault::None,
1299            #[cfg(test)]
1300            Self::RollbackDestinationDirectory => ActivationFault::None,
1301            #[cfg(test)]
1302            Self::FinalProvenanceCommitFailure => ActivationFault::None,
1303        }
1304    }
1305
1306    fn after_stop(&self, _plugin_dir: &Path) -> std::io::Result<()> {
1307        match self {
1308            #[cfg(test)]
1309            Self::ReplaceLiveAfterStop => {
1310                let parent = _plugin_dir.parent().ok_or_else(|| {
1311                    std::io::Error::new(
1312                        std::io::ErrorKind::InvalidInput,
1313                        "plugin directory has no parent",
1314                    )
1315                })?;
1316                let displaced =
1317                    parent.join(format!(".fault-displaced-live-{}", uuid::Uuid::new_v4()));
1318                rename_noreplace(_plugin_dir, &displaced)?;
1319                std::fs::create_dir(_plugin_dir)?;
1320                std::fs::write(_plugin_dir.join("RACE_MARKER"), b"race-owned")
1321            }
1322            _ => Ok(()),
1323        }
1324    }
1325
1326    fn injected_install_error(&self) -> Option<PluginError> {
1327        match self {
1328            #[cfg(test)]
1329            Self::RollbackDestinationDirectory => Some(PluginError::Registration(
1330                "injected install failure before rollback destination race".to_string(),
1331            )),
1332            _ => None,
1333        }
1334    }
1335
1336    fn rollback_fault(&self) -> RollbackFault {
1337        match self {
1338            #[cfg(test)]
1339            Self::RollbackDestinationDirectory => RollbackFault::ReplaceDestinationDirectory,
1340            _ => RollbackFault::None,
1341        }
1342    }
1343
1344    #[cfg(test)]
1345    fn fail_final_provenance_commit(&self) -> bool {
1346        matches!(self, Self::FinalProvenanceCommitFailure)
1347    }
1348}
1349
1350fn stopped_upgrade_failure(error: PluginError, stopped_services: &[String]) -> PluginError {
1351    if stopped_services.is_empty() {
1352        return error;
1353    }
1354    PluginError::Registration(format!(
1355        "{error}; upgrade failed after stopping service(s) [{}]; automatic restart is disabled, so they remain stopped pending manual recovery",
1356        stopped_services.join(", ")
1357    ))
1358}
1359
1360#[cfg(test)]
1361async fn install_server_plugin_from_source_with_fault(
1362    installer: &ServerPluginInstaller,
1363    input: PluginSourceInput,
1364    plugins_root: &Path,
1365    trust: &PluginTrustConfig,
1366    disposition: InstallDisposition,
1367    expected_plugin_id: Option<&str>,
1368    fault: ServerSourceFault,
1369) -> PluginResult<InstalledPlugin> {
1370    install_server_plugin_from_source_inner(
1371        installer,
1372        input,
1373        plugins_root,
1374        trust,
1375        disposition,
1376        expected_plugin_id,
1377        None,
1378        fault,
1379    )
1380    .await
1381}
1382
1383async fn install_server_plugin_from_source_inner(
1384    installer: &ServerPluginInstaller,
1385    input: PluginSourceInput,
1386    plugins_root: &Path,
1387    trust: &PluginTrustConfig,
1388    disposition: InstallDisposition,
1389    expected_plugin_id: Option<&str>,
1390    requested_grants: Option<&[EventSinkGrantRequest]>,
1391    fault: ServerSourceFault,
1392) -> PluginResult<InstalledPlugin> {
1393    let prepared = prepare_plugin_source(input, plugins_root, trust).await?;
1394    if let Some(expected_plugin_id) = expected_plugin_id {
1395        if prepared.manifest.id != expected_plugin_id {
1396            let manifest_id = prepared.manifest.id.clone();
1397            prepared.discard().await;
1398            return Err(PluginError::InvalidManifest(format!(
1399                "path id '{expected_plugin_id}' does not match the source's manifest id '{manifest_id}'"
1400            )));
1401        }
1402    }
1403
1404    let plugin_id = prepared.manifest.id.clone();
1405    let guard = installer.begin_operation().await;
1406    let previous = match installer
1407        .preflight_prepared_candidate(
1408            &prepared.manifest,
1409            &prepared.prepared_dir,
1410            disposition,
1411            &guard,
1412        )
1413        .await
1414    {
1415        Ok(previous) => previous,
1416        Err(error) => {
1417            prepared.discard().await;
1418            return Err(error);
1419        }
1420    };
1421    let event_sink_grants: EventSinkPermissionGrants = match resolve_event_sink_grants(
1422        &prepared.manifest,
1423        previous.as_ref().map(|entry| &entry.registered),
1424        requested_grants,
1425    ) {
1426        Ok(grants) => grants,
1427        Err(error) => {
1428            prepared.discard().await;
1429            return Err(error);
1430        }
1431    };
1432    if disposition == InstallDisposition::Upgrade {
1433        let Some(previous) = previous.as_ref() else {
1434            prepared.discard().await;
1435            return Err(PluginError::Registration(format!(
1436                "upgrade for '{plugin_id}' has no unique previous provenance row"
1437            )));
1438        };
1439        if previous.plugin_dir != prepared.plugin_dir {
1440            let fixed = prepared.plugin_dir.display().to_string();
1441            let recorded = previous.plugin_dir.display().to_string();
1442            prepared.discard().await;
1443            return Err(PluginError::Registration(format!(
1444                "upgrade for '{plugin_id}' requires previous provenance at fixed bundle path '{fixed}', but installed.json records '{recorded}'"
1445            )));
1446        }
1447    }
1448
1449    // Capture the exact old directory identity while services are still
1450    // running. Activation must consume this snapshot rather than blessing
1451    // whatever happens to occupy the same path after shutdown.
1452    let expected_live = match prepared.capture_expected_live() {
1453        Ok(snapshot) => snapshot,
1454        Err(error) => {
1455            prepared.discard().await;
1456            return Err(PluginError::Registration(format!(
1457                "could not capture the live plugin bundle before service shutdown: {error}"
1458            )));
1459        }
1460    };
1461    match disposition {
1462        InstallDisposition::Upgrade if expected_live.is_none() => {
1463            prepared.discard().await;
1464            return Err(PluginError::Registration(format!(
1465                "upgrade for '{plugin_id}' requires an exact live bundle at '{}', but none existed before service shutdown",
1466                plugins_root.join(&plugin_id).display()
1467            )));
1468        }
1469        InstallDisposition::FailIfInstalled if expected_live.is_some() => {
1470            prepared.discard().await;
1471            return Err(PluginError::Registration(format!(
1472                "fresh install expected no live bundle at '{}', but an existing destination was captured; manual bundle recovery is required",
1473                plugins_root.join(&plugin_id).display()
1474            )));
1475        }
1476        _ => {}
1477    }
1478
1479    let stopped_services = if disposition == InstallDisposition::Upgrade {
1480        installer.stop_services_for_upgrade(&plugin_id).await
1481    } else {
1482        Vec::new()
1483    };
1484    if let Err(error) = fault.after_stop(&prepared.plugin_dir) {
1485        prepared.discard().await;
1486        return Err(stopped_upgrade_failure(
1487            PluginError::Registration(format!(
1488                "failed while exercising the post-stop source transaction boundary: {error}; manual bundle recovery is required"
1489            )),
1490            &stopped_services,
1491        ));
1492    }
1493    let staged = match prepared
1494        .activate_inner(expected_live, fault.activation_fault())
1495        .await
1496    {
1497        Ok(staged) => staged,
1498        Err(failure) => {
1499            let error = failure.into_plugin_error();
1500            return Err(stopped_upgrade_failure(error, &stopped_services));
1501        }
1502    };
1503
1504    let manifest = staged.manifest.clone();
1505    let plugin_dir = staged.plugin_dir.clone();
1506    let source = staged.source.clone();
1507    let install_result = match fault.injected_install_error() {
1508        Some(error) => Err(error),
1509        None => {
1510            #[cfg(test)]
1511            {
1512                if fault.fail_final_provenance_commit() {
1513                    installer
1514                        .install_with_operation_failing_final_commit(
1515                            &manifest,
1516                            &plugin_dir,
1517                            source,
1518                            disposition,
1519                            chrono::Utc::now(),
1520                            Some(&event_sink_grants),
1521                            &guard,
1522                        )
1523                        .await
1524                } else {
1525                    installer
1526                        .install_with_operation_and_event_sink_grants(
1527                            &manifest,
1528                            &plugin_dir,
1529                            source,
1530                            disposition,
1531                            chrono::Utc::now(),
1532                            &event_sink_grants,
1533                            &guard,
1534                        )
1535                        .await
1536                }
1537            }
1538            #[cfg(not(test))]
1539            {
1540                installer
1541                    .install_with_operation_and_event_sink_grants(
1542                        &manifest,
1543                        &plugin_dir,
1544                        source,
1545                        disposition,
1546                        chrono::Utc::now(),
1547                        &event_sink_grants,
1548                        &guard,
1549                    )
1550                    .await
1551            }
1552        }
1553    };
1554    match install_result {
1555        Ok(entry) => {
1556            staged.commit().await;
1557            Ok(entry)
1558        }
1559        Err(error) => {
1560            let recovery = staged.rollback_inner(fault.rollback_fault()).await;
1561            Err(stopped_upgrade_failure(
1562                recovery.wrap_error(error),
1563                &stopped_services,
1564            ))
1565        }
1566    }
1567}
1568
1569/// Stage + `install()` + commit/rollback for a standalone installer that does
1570/// not share bamboo-server's capability stores or operation lock. Server code
1571/// must use [`install_server_plugin_from_source`] instead.
1572#[cfg(test)]
1573async fn install_plugin_from_source(
1574    installer: &dyn PluginInstaller,
1575    input: PluginSourceInput,
1576    plugins_root: &Path,
1577    trust: &PluginTrustConfig,
1578    disposition: InstallDisposition,
1579) -> PluginResult<InstalledPlugin> {
1580    let staged = stage_plugin_source(input, plugins_root, trust).await?;
1581    let manifest = staged.manifest.clone();
1582    let plugin_dir = staged.plugin_dir.clone();
1583    let source = staged.source.clone();
1584
1585    match installer
1586        .install(
1587            &manifest,
1588            &plugin_dir,
1589            source,
1590            disposition,
1591            chrono::Utc::now(),
1592        )
1593        .await
1594    {
1595        Ok(entry) => {
1596            staged.commit().await;
1597            Ok(entry)
1598        }
1599        Err(error) => {
1600            let recovery = staged.rollback().await;
1601            Err(recovery.wrap_error(error))
1602        }
1603    }
1604}
1605
1606async fn stage_into(
1607    input: &PluginSourceInput,
1608    staging_dir: &Path,
1609    trust: &PluginTrustConfig,
1610    max_decompressed_bytes: u64,
1611) -> PluginResult<(PluginManifest, PluginSource)> {
1612    match input {
1613        PluginSourceInput::LocalDir(path) => {
1614            copy_dir_recursive(path, staging_dir).await?;
1615            let manifest = read_and_parse_manifest(staging_dir).await?;
1616            Ok((manifest, PluginSource::LocalDir { path: path.clone() }))
1617        }
1618        PluginSourceInput::LocalArchive(path) => {
1619            let bytes = tokio::fs::read(path).await?;
1620            let kind = detect_archive_kind(&path.to_string_lossy()).ok_or_else(|| {
1621                PluginError::InvalidManifest(format!(
1622                    "unsupported archive extension for '{}': expected .zip/.tar.gz/.tgz",
1623                    path.display()
1624                ))
1625            })?;
1626            extract_archive(
1627                bytes,
1628                kind,
1629                staging_dir.to_path_buf(),
1630                max_decompressed_bytes,
1631            )
1632            .await?;
1633            flatten_if_single_subdir(staging_dir).await?;
1634            let manifest = read_and_parse_manifest(staging_dir).await?;
1635            Ok((manifest, PluginSource::LocalArchive { path: path.clone() }))
1636        }
1637        PluginSourceInput::Url {
1638            url,
1639            sha256,
1640            allow_unverified,
1641            allow_untrusted_host,
1642            allow_unsigned,
1643            insecure,
1644        } => {
1645            let flags = UrlTrustFlags {
1646                sha256: sha256.as_deref(),
1647                allow_unverified: *allow_unverified,
1648                allow_untrusted_host: *allow_untrusted_host,
1649                allow_unsigned: *allow_unsigned,
1650                insecure: *insecure,
1651            };
1652            let fetched =
1653                fetch_manifest_bundle(url, flags, trust, staging_dir, max_decompressed_bytes)
1654                    .await?;
1655
1656            // Security (issue #479 §4 / open question 6): a manifest
1657            // declaring `provides.services` is the highest-trust plugin
1658            // artifact kind — a resident, unconstrained process — so it may
1659            // NEVER install from a URL source whose bytes weren't
1660            // cryptographically signed by a trusted key, no matter which
1661            // opt-out flag got it this far (`allow_unsigned` explicitly, or
1662            // the `--insecure`/`plugin_trust.enforcement: off` aggregate).
1663            // `fetched.signed_by.is_none()` is exactly that "unsigned"
1664            // signal regardless of WHY (genuinely unsigned bundle, or an
1665            // opt-out that let an unsigned/mismatched one through) — see
1666            // `fetch_manifest_bundle`'s layer-2 doc comment. Checked here
1667            // (not in `PluginManifest::validate`, which has no visibility
1668            // into install-time trust flags/signature results) and BEFORE
1669            // the per-platform binary artifact is fetched, so a refused
1670            // install downloads no executable at all.
1671            if !fetched.manifest.provides.services.is_empty() && fetched.signed_by.is_none() {
1672                return Err(PluginError::UnsignedOrUntrustedSignature(format!(
1673                    "refusing to install plugin '{}' from '{url}': it declares `provides.services` \
1674                     (long-running service plugins are the highest-trust artifact kind) but its \
1675                     bundle is unsigned or its signature does not verify against a trusted key — \
1676                     `--allow-unsigned`/`--insecure` and `plugin_trust.enforcement: off` are NOT \
1677                     honoured for a services-declaring manifest; publish a signature from a \
1678                     trusted key instead",
1679                    fetched.manifest.id
1680                )));
1681            }
1682
1683            // Binary-artifact verification stays as defense in depth (see
1684            // the module docs) — its own sha256, declared inside the
1685            // now-verified manifest, is checked in
1686            // `fetch_and_place_artifact`, but no longer double-duty as the
1687            // `PluginSource::Url` provenance hash: that's the bundle's own
1688            // verified sha256 now, computed above.
1689            fetch_and_place_artifact(&fetched.manifest, staging_dir, max_decompressed_bytes)
1690                .await?;
1691            Ok((
1692                fetched.manifest,
1693                PluginSource::Url {
1694                    url: url.clone(),
1695                    sha256: fetched.verified_sha256,
1696                    allow_unverified: *allow_unverified,
1697                    allow_untrusted_host: *allow_untrusted_host,
1698                    allow_unsigned: *allow_unsigned,
1699                    signed_by: fetched.signed_by,
1700                    // The AGGREGATE, not the raw per-install `insecure` flag:
1701                    // recorded `true` whenever ALL three layers were actually
1702                    // skipped for this install, whether that came from the
1703                    // per-install flag or from `plugin_trust.enforcement:
1704                    // off` (see `fetch_manifest_bundle`) — either way, this is
1705                    // the single source of truth for "was this install done
1706                    // insecurely" that `plugin list`/audit needs.
1707                    insecure: fetched.insecure_aggregate,
1708                },
1709            ))
1710        }
1711    }
1712}
1713
1714// ---------------------------------------------------------------------
1715// Manifest bundle fetch (URL source)
1716// ---------------------------------------------------------------------
1717
1718/// The caller-supplied bits of a [`PluginSourceInput::Url`] that
1719/// [`fetch_manifest_bundle`] needs, grouped into one struct purely to keep
1720/// that function's parameter count sane (`PluginSourceInput::Url` itself
1721/// carries the same five fields, plus `url`, which stays a separate
1722/// top-level parameter since [`fetch_and_verify_signature`] and the sha256
1723/// helpers all key off it directly).
1724struct UrlTrustFlags<'a> {
1725    sha256: Option<&'a str>,
1726    allow_unverified: bool,
1727    allow_untrusted_host: bool,
1728    allow_unsigned: bool,
1729    /// The per-install `--insecure` / `"insecure": true` aggregate opt-out
1730    /// (see the module docs' "`--insecure` / `plugin_trust.enforcement`"
1731    /// section). ORed with `trust.enforcement_is_off()` inside
1732    /// [`fetch_manifest_bundle`] to compute the EFFECTIVE aggregate for this
1733    /// install — a config-level `enforcement: off` has the same effect as
1734    /// this flag without the caller having to set it.
1735    insecure: bool,
1736}
1737
1738/// Everything [`fetch_manifest_bundle`] hands back to [`stage_into`].
1739struct FetchedBundle {
1740    manifest: PluginManifest,
1741    /// The verified bundle sha256 (`None` unless a `sha256` was supplied and
1742    /// confirmed) — for [`PluginSource::Url`] provenance.
1743    verified_sha256: Option<String>,
1744    /// The trusted key label the signature verified against (`None` if the
1745    /// install proceeded unsigned via `allow_unsigned`/the insecure
1746    /// aggregate).
1747    signed_by: Option<String>,
1748    /// The EFFECTIVE aggregate for this install: `true` when ALL three trust
1749    /// layers were skipped, whether that came from the per-install
1750    /// `insecure` flag or from `plugin_trust.enforcement: off`. This is what
1751    /// [`PluginSource::Url::insecure`] provenance records — see
1752    /// [`stage_into`].
1753    insecure_aggregate: bool,
1754}
1755
1756/// Fetch `url`: either a bare `plugin.json` or an archive containing one
1757/// (same root-or-single-subdir rule as [`PluginSourceInput::LocalArchive`]).
1758/// Populates `staging_dir` with whatever the bundle contains (just
1759/// `plugin.json` for a bare manifest; the full skills/prompts/workflows tree
1760/// for an archive).
1761///
1762/// **Three trust layers, enforced in this order** (see the module docs'
1763/// summary):
1764///
1765/// 1. **Host allowlist.** Before the URL is even requested: if it is not
1766///    `https` with a `<host><path>` matching one of `trust.trusted_hosts` as a
1767///    prefix, refuses with [`PluginError::UntrustedHost`] — no network access
1768///    happens for a refused install — unless `allow_untrusted_host` is `true`
1769///    (logged).
1770/// 2. **Signature.** Once the bundle is downloaded, `<url>.sig` is fetched
1771///    (a missing/unreachable sidecar is treated identically to a malformed
1772///    one — see [`fetch_and_verify_signature`]) and checked against every
1773///    `algorithm: "ed25519"` entry in `trust.trusted_keys`. A match records
1774///    that key's label; no match refuses with
1775///    [`PluginError::UnsignedOrUntrustedSignature`] unless `allow_unsigned`
1776///    is `true` (logged).
1777/// 3. **Checksum.** If `sha256` is `None`, `allow_unverified` is `false`, AND
1778///    the bundle was NOT signature-verified in step 2, refuses with
1779///    [`PluginError::ChecksumRequired`] — a verified signature already proves
1780///    integrity+authenticity more strongly than a pasted hash, so it
1781///    satisfies this layer on its own (an `allow_unsigned` bypass grants no
1782///    such credit: an unsigned install still needs its own
1783///    `sha256`/`allow_unverified`, exactly as before this branch). If
1784///    `sha256` IS given (signed or not), the downloaded bytes are still
1785///    hashed and compared (case-insensitive) BEFORE any extraction/parsing —
1786///    a mismatch is [`PluginError::BundleVerificationFailed`] regardless of
1787///    signature status.
1788///
1789/// Before any of the three layers run, the EFFECTIVE aggregate is computed:
1790/// `flags.insecure || trust.enforcement_is_off()`. When `true`,
1791/// `allow_untrusted_host`/`allow_unsigned`/`allow_unverified` are all treated
1792/// as `true` for the rest of this call (see the module docs'
1793/// "`--insecure` / `plugin_trust.enforcement`" section) and a prominent
1794/// `tracing::warn!` names the source URL — this does NOT waive the `sha256`
1795/// check itself: a supplied hash is still verified in step 3 below.
1796///
1797/// Returns a [`FetchedBundle`]: the parsed manifest, the verified bundle
1798/// sha256 (`None` unless a `sha256` was supplied and confirmed — for
1799/// [`PluginSource::Url`] provenance), the trusted key label the signature
1800/// verified against (`None` if the install proceeded unsigned via
1801/// `allow_unsigned`/the aggregate), and the effective insecure-aggregate flag
1802/// itself (for `PluginSource::Url::insecure` provenance).
1803async fn fetch_manifest_bundle(
1804    url: &str,
1805    flags: UrlTrustFlags<'_>,
1806    trust: &PluginTrustConfig,
1807    staging_dir: &Path,
1808    max_decompressed_bytes: u64,
1809) -> PluginResult<FetchedBundle> {
1810    let UrlTrustFlags {
1811        sha256,
1812        allow_unverified,
1813        allow_untrusted_host,
1814        allow_unsigned,
1815        insecure,
1816    } = flags;
1817
1818    // The convenience aggregate: a per-install `--insecure` flag OR a
1819    // config-level `plugin_trust.enforcement: off` both mean "skip all three
1820    // layers for this install" — computed once, up front, so every layer
1821    // below sees the SAME effective flags regardless of which of the two
1822    // triggered it. Shadowing the original `allow_*` bindings means the rest
1823    // of this function needs no further special-casing.
1824    let insecure_aggregate = insecure || trust.enforcement_is_off();
1825    if insecure_aggregate {
1826        tracing::warn!(
1827            %url,
1828            "installing plugin from '{url}' with ALL trust checks disabled (insecure) — host \
1829             allowlist, signature and checksum-required-by-default are all skipped for this \
1830             install (a supplied --sha256, if any, is still verified)"
1831        );
1832    }
1833    let allow_untrusted_host = allow_untrusted_host || insecure_aggregate;
1834    let allow_unsigned = allow_unsigned || insecure_aggregate;
1835    let allow_unverified = allow_unverified || insecure_aggregate;
1836
1837    // Layer 1: host allowlist — refuse BEFORE any network access.
1838    if !trust.is_host_trusted(url) {
1839        if !allow_untrusted_host {
1840            return Err(PluginError::UntrustedHost(format!(
1841                "refusing to install plugin bundle from '{url}': its host is not in the \
1842                 `plugin_trust.trusted_hosts` allowlist (config.json) — add a matching \
1843                 host+path prefix there, or explicitly accept the risk (CLI: \
1844                 `--allow-untrusted-host`; HTTP: `\"allow_untrusted_host\": true`)"
1845            )));
1846        }
1847        tracing::warn!(
1848            %url,
1849            "installing plugin bundle from a host outside `plugin_trust.trusted_hosts` \
1850             (allow_untrusted_host opt-out)"
1851        );
1852    }
1853
1854    // Redirect policy (BLOCKER 1 fix): whether the downloaded bytes WILL be
1855    // cryptographically authenticated determines whether it's safe to follow
1856    // a redirect. A signature is REQUIRED whenever `!allow_unsigned` — even
1857    // though it hasn't been fetched/checked yet, an unverified-but-required
1858    // signature refuses the install below regardless of which host actually
1859    // served the bytes, so following a redirect to get here is harmless.
1860    // Likewise a supplied `sha256` is checked (and refused on mismatch) below
1861    // regardless of the serving host. Only when NEITHER control is in play —
1862    // `allow_unsigned` AND no `sha256` — is the host allowlist the SOLE
1863    // authority over where these bytes came from; it only vetted this exact
1864    // URL, so redirects must be disabled in that case (see
1865    // `http_client_no_redirects`). The SAME client is used for both the
1866    // bundle fetch and the `.sig` fetch below, for one install.
1867    let bytes_will_be_authenticated = !allow_unsigned || sha256.is_some();
1868    let client = if bytes_will_be_authenticated {
1869        http_client_following_redirects()
1870    } else {
1871        http_client_no_redirects()
1872    };
1873
1874    let bytes = download_bytes(client, url, MAX_DOWNLOAD_BYTES).await?;
1875
1876    // Layer 2: signature — a valid signature is a STRONGER integrity +
1877    // authenticity guarantee than a pasted checksum (see layer 3 below).
1878    let signed_by = fetch_and_verify_signature(client, url, &bytes, &trust.trusted_keys).await;
1879    if signed_by.is_none() {
1880        if !allow_unsigned {
1881            return Err(PluginError::UnsignedOrUntrustedSignature(format!(
1882                "refusing to install plugin bundle from '{url}': it is unsigned, or its \
1883                 '{url}.sig' does not verify against any key in `plugin_trust.trusted_keys` \
1884                 (config.json) — publish a signature from a trusted key, or explicitly accept \
1885                 the risk (CLI: `--allow-unsigned`; HTTP: `\"allow_unsigned\": true`)"
1886            )));
1887        }
1888        tracing::warn!(
1889            %url,
1890            "installing an unsigned (or untrusted-signature) plugin bundle (allow_unsigned opt-out)"
1891        );
1892    }
1893
1894    // Layer 3: checksum — superseded by a verified signature (layer 2), but
1895    // otherwise unchanged.
1896    if sha256.is_none() && !allow_unverified && signed_by.is_none() {
1897        return Err(PluginError::ChecksumRequired(format!(
1898            "refusing to install plugin bundle from '{url}' without a checksum — pass the \
1899             bundle's sha256 (from the release page / a trusted source) to verify it before \
1900             install (CLI: `--sha256 <hex>`; HTTP: `\"sha256\": \"<hex>\"` on the url source), \
1901             or explicitly accept the risk of an unverified download (CLI: \
1902             `--allow-unverified`; HTTP: `\"allow_unverified\": true`)"
1903        )));
1904    }
1905
1906    let verified_sha256 = match sha256 {
1907        Some(expected) => {
1908            let actual = sha256_hex(&bytes);
1909            if !actual.eq_ignore_ascii_case(expected) {
1910                return Err(PluginError::BundleVerificationFailed(format!(
1911                    "sha256 mismatch for plugin bundle '{url}': expected {expected}, downloaded \
1912                     bytes hash to {actual} — refusing to unpack (the bundle may be tampered, \
1913                     corrupted, or the wrong sha256 was supplied)"
1914                )));
1915            }
1916            Some(actual)
1917        }
1918        None => {
1919            if signed_by.is_none() {
1920                tracing::warn!(
1921                    %url,
1922                    "installing plugin bundle from a URL with no checksum verification \
1923                     (allow_unverified opt-out) — the download is trusted on HTTPS alone"
1924                );
1925            }
1926            None
1927        }
1928    };
1929
1930    let manifest = if let Some(kind) = detect_archive_kind(url) {
1931        extract_archive(
1932            bytes,
1933            kind,
1934            staging_dir.to_path_buf(),
1935            max_decompressed_bytes,
1936        )
1937        .await?;
1938        flatten_if_single_subdir(staging_dir).await?;
1939        read_and_parse_manifest(staging_dir).await?
1940    } else {
1941        let raw = String::from_utf8(bytes).map_err(|_| {
1942            PluginError::InvalidManifest(format!("manifest at '{url}' is not valid UTF-8"))
1943        })?;
1944        tokio::fs::create_dir_all(staging_dir).await?;
1945        tokio::fs::write(staging_dir.join("plugin.json"), &raw).await?;
1946        PluginManifest::parse_str(&raw)?
1947    };
1948
1949    Ok(FetchedBundle {
1950        manifest,
1951        verified_sha256,
1952        signed_by,
1953        insecure_aggregate,
1954    })
1955}
1956
1957/// Fetch `<url>.sig` and verify it against `bundle_bytes` for every
1958/// `algorithm: "ed25519"` entry in `trusted_keys`. Returns the label of the
1959/// FIRST trusted key the signature verifies against, or `None` if the
1960/// sidecar is missing/unfetchable, malformed (not 128 hex chars — a raw
1961/// 64-byte ed25519 signature, trailing whitespace trimmed), or does not
1962/// verify against any trusted key. All of those failure modes are treated
1963/// identically on purpose: an attacker serving a garbage/absent `.sig` must
1964/// not be distinguishable from "genuinely unsigned" by anything this
1965/// function returns.
1966async fn fetch_and_verify_signature(
1967    client: &reqwest::Client,
1968    url: &str,
1969    bundle_bytes: &[u8],
1970    trusted_keys: &[bamboo_config::TrustedKey],
1971) -> Option<String> {
1972    // Plain release-asset URLs (no query string) are the supported case: this
1973    // just appends `.sig`, which would misplace the suffix AFTER a query
1974    // string on a URL like `.../plugin.json?token=...` (`.../plugin.json?token=....sig`,
1975    // not the sidecar). Plugin bundle URLs are typically bare release-asset
1976    // URLs with no query string; if that ever needs to change, insert `.sig`
1977    // before the `?` instead of blindly appending it.
1978    let sig_url = format!("{url}.sig");
1979    let sig_bytes = download_bytes(client, &sig_url, MAX_SIGNATURE_DOWNLOAD_BYTES)
1980        .await
1981        .ok()?;
1982    let sig_text = String::from_utf8(sig_bytes).ok()?;
1983    let sig_raw = hex::decode(sig_text.trim()).ok()?;
1984    let sig_array: [u8; 64] = sig_raw.try_into().ok()?;
1985    let signature = ed25519_dalek::Signature::from_bytes(&sig_array);
1986
1987    for key in trusted_keys {
1988        if !key.algorithm.eq_ignore_ascii_case("ed25519") {
1989            continue;
1990        }
1991        let Ok(pub_raw) = hex::decode(&key.public_key) else {
1992            continue;
1993        };
1994        let Ok(pub_array) = <[u8; 32]>::try_from(pub_raw.as_slice()) else {
1995            continue;
1996        };
1997        let Ok(verifying_key) = ed25519_dalek::VerifyingKey::from_bytes(&pub_array) else {
1998            continue;
1999        };
2000        if verifying_key.verify(bundle_bytes, &signature).is_ok() {
2001            return Some(key.label.clone());
2002        }
2003    }
2004    None
2005}
2006
2007/// Per-platform binary artifact fetch (URL source only). Fetches the
2008/// artifact declared for [`Platform::current`] (a no-op if the manifest
2009/// declares none for this platform — not every plugin needs a binary),
2010/// verifies its sha256 BEFORE unpacking, and places the single expected
2011/// executable at `<staging_dir>/bin/<platform>/<id>[.exe]`.
2012///
2013/// This stays as defense in depth alongside [`fetch_manifest_bundle`]'s
2014/// bundle-sha256 check (see the module docs): the artifact hash is declared
2015/// INSIDE the manifest, so on its own it only proves "the binary matches
2016/// what this bundle's manifest says", not "this bundle itself is what the
2017/// caller expected" — that's what the bundle-level check now provides. The
2018/// verified artifact sha256 is therefore no longer surfaced to the caller
2019/// (it used to double as [`PluginSource::Url`]'s provenance hash before the
2020/// bundle-level check existed); this function's contract is purely
2021/// verify-then-place.
2022async fn fetch_and_place_artifact(
2023    manifest: &PluginManifest,
2024    staging_dir: &Path,
2025    max_decompressed_bytes: u64,
2026) -> PluginResult<()> {
2027    let Some(platform) = Platform::current() else {
2028        return Ok(());
2029    };
2030    let Some(artifact) = manifest.artifacts.get(platform.as_str()) else {
2031        return Ok(());
2032    };
2033
2034    // The artifact's sha256 is verified BELOW, unconditionally (no bypass
2035    // flag exists for it — see this function's docs) — the downloaded bytes
2036    // are always cryptographically authenticated here, so redirects are
2037    // always safe to follow for this fetch.
2038    let bytes = download_bytes(
2039        http_client_following_redirects(),
2040        &artifact.url,
2041        MAX_DOWNLOAD_BYTES,
2042    )
2043    .await?;
2044    let actual_sha256 = sha256_hex(&bytes);
2045    if !actual_sha256.eq_ignore_ascii_case(&artifact.sha256) {
2046        return Err(PluginError::ArtifactVerificationFailed(format!(
2047            "sha256 mismatch for '{}': manifest declares {}, downloaded bytes hash to {}",
2048            artifact.url, artifact.sha256, actual_sha256
2049        )));
2050    }
2051
2052    let kind = detect_archive_kind(&artifact.url).ok_or_else(|| {
2053        PluginError::InvalidManifest(format!(
2054            "artifact url '{}' is not a .zip/.tar.gz/.tgz",
2055            artifact.url
2056        ))
2057    })?;
2058
2059    let scratch_dir = staging_dir.join(format!(".artifact-scratch-{}", platform.as_str()));
2060    extract_archive(bytes, kind, scratch_dir.clone(), max_decompressed_bytes).await?;
2061
2062    let expected_name = if matches!(platform, Platform::Windows) {
2063        format!("{}.exe", manifest.id)
2064    } else {
2065        manifest.id.clone()
2066    };
2067    let source_bin = scratch_dir.join(&expected_name);
2068    if !tokio::fs::try_exists(&source_bin).await.unwrap_or(false) {
2069        return Err(PluginError::InvalidManifest(format!(
2070            "artifact archive for platform '{}' does not contain the expected root executable '{}'",
2071            platform.as_str(),
2072            expected_name
2073        )));
2074    }
2075
2076    let dest_dir = staging_dir.join("bin").join(platform.as_str());
2077    tokio::fs::create_dir_all(&dest_dir).await?;
2078    let dest_bin = dest_dir.join(&expected_name);
2079    move_file(&source_bin, &dest_bin).await?;
2080
2081    #[cfg(unix)]
2082    {
2083        use std::os::unix::fs::PermissionsExt;
2084        let mut perms = tokio::fs::metadata(&dest_bin).await?.permissions();
2085        perms.set_mode(0o755);
2086        tokio::fs::set_permissions(&dest_bin, perms).await?;
2087    }
2088
2089    tokio::fs::remove_dir(&scratch_dir).await.map_err(|error| {
2090        PluginError::InvalidManifest(format!(
2091            "artifact archive for platform '{}' must contain only the expected root executable '{}': {error}",
2092            platform.as_str(),
2093            expected_name
2094        ))
2095    })?;
2096    Ok(())
2097}
2098
2099/// `rename`, falling back to copy+remove across a device boundary.
2100async fn move_file(source: &Path, dest: &Path) -> PluginResult<()> {
2101    if tokio::fs::rename(source, dest).await.is_ok() {
2102        return Ok(());
2103    }
2104    let data = tokio::fs::read(source).await?;
2105    tokio::fs::write(dest, data).await?;
2106    tokio::fs::remove_file(source).await?;
2107    Ok(())
2108}
2109
2110// ---------------------------------------------------------------------
2111// HTTP fetch
2112// ---------------------------------------------------------------------
2113
2114/// Client used whenever the downloaded bytes WILL be cryptographically
2115/// authenticated — a signature is required (`!allow_unsigned`) or a `sha256`
2116/// pin was supplied. Following redirects is safe here: whichever host
2117/// actually served the final bytes, the signature/checksum check downstream
2118/// refuses on a bad result regardless — this is what lets the default
2119/// official-signed-via-CDN flow (GitHub Releases 302-redirecting to
2120/// `objects.githubusercontent.com`) and the checksummed flow keep working.
2121/// Reuses the workspace's pinned (native-tls) `reqwest` — never construct a
2122/// second client/connector here (see `notify_sinks::ntfy`'s identical
2123/// pattern).
2124fn http_client_following_redirects() -> &'static reqwest::Client {
2125    static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
2126    CLIENT.get_or_init(|| {
2127        reqwest::Client::builder()
2128            .redirect(reqwest::redirect::Policy::limited(10))
2129            .build()
2130            .expect("a reqwest client with only a redirect policy set always builds")
2131    })
2132}
2133
2134/// Client used whenever NEITHER a signature nor a checksum will authenticate
2135/// the downloaded bytes (`allow_unsigned && sha256.is_none()` — the fully
2136/// opted-out "host-only trust" case). The host allowlist (layer 1) only
2137/// vetted the FIRST hop's `<host><path>`; a transparent redirect would let
2138/// the bytes actually come from anywhere, silently defeating the allowlist
2139/// as the sole control. Redirects are disabled so a server that tries to
2140/// redirect is refused outright (see [`download_bytes`]) rather than quietly
2141/// followed — the approved host must serve the bytes directly.
2142fn http_client_no_redirects() -> &'static reqwest::Client {
2143    static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
2144    CLIENT.get_or_init(|| {
2145        reqwest::Client::builder()
2146            .redirect(reqwest::redirect::Policy::none())
2147            .build()
2148            .expect("a reqwest client with only a redirect policy set always builds")
2149    })
2150}
2151
2152/// Hard ceiling on any single plugin download (manifest, bundle, or binary
2153/// artifact archive). A malicious or misconfigured URL must not be able to
2154/// stream an unbounded body into memory (OOM DoS). 256 MiB is generous for a
2155/// plugin bundle + one platform binary while still bounding the worst case.
2156const MAX_DOWNLOAD_BYTES: u64 = 256 * 1024 * 1024;
2157
2158/// Hard ceiling on the `.sig` sidecar fetch specifically ([`fetch_and_verify_signature`]).
2159/// A valid signature is exactly 128 hex chars (a raw 64-byte ed25519
2160/// signature, hex-encoded) — 4 KiB is already wildly generous. Capping it far
2161/// below [`MAX_DOWNLOAD_BYTES`] means a malicious/misconfigured host serving
2162/// a `.sig` route can't force multiple hundreds of MiB into memory before the
2163/// hex-decode simply fails on the (way too long) body.
2164const MAX_SIGNATURE_DOWNLOAD_BYTES: u64 = 4 * 1024;
2165
2166/// Hard ceiling on the TOTAL decompressed bytes any single archive (zip or
2167/// tar.gz) may expand to across ALL of its entries combined. Complements
2168/// `MAX_DOWNLOAD_BYTES`, which only bounds the COMPRESSED bytes fetched over
2169/// the wire — a small, highly-compressible archive (a classic
2170/// decompression/"zip bomb") can still expand to many gigabytes on disk with
2171/// nothing capping the output side. Enforced incrementally DURING extraction
2172/// (see [`copy_capped`]) against the ACTUAL bytes read off the decompression
2173/// stream — never an entry's header-declared size, which a crafted archive
2174/// can misstate (a zip's `uncompressed_size` field in particular is pure
2175/// metadata the reader doesn't have to honor) — so a bomb is aborted close to
2176/// this ceiling rather than after it has already exhausted disk. 2 GiB is
2177/// generous for any legitimate plugin bundle (skills/prompts/workflows text
2178/// plus, at most, one platform binary) while still bounding the worst case.
2179const MAX_DECOMPRESSED_BYTES: u64 = 2 * 1024 * 1024 * 1024;
2180
2181/// Fetch `url` via `client`, capping the body at `max_bytes`. `client`'s
2182/// redirect policy is the caller's decision (see
2183/// [`http_client_following_redirects`] / [`http_client_no_redirects`]) — this
2184/// function additionally refuses outright, with [`PluginError::RedirectRefused`]
2185/// (a clean 403-family trust refusal, NOT a 500), if the FINAL response it
2186/// receives is itself still a redirect (3xx), which only happens when
2187/// `client` was built with `redirect::Policy::none()` and the server actually
2188/// tried to redirect: that means the request's trust flags decided the bytes
2189/// must come from the vetted host directly (see BLOCKER 1 in the source-trust
2190/// review / the module docs), so silently treating the redirect response as
2191/// the payload would defeat that decision.
2192async fn download_bytes(
2193    client: &reqwest::Client,
2194    url: &str,
2195    max_bytes: u64,
2196) -> PluginResult<Vec<u8>> {
2197    use futures::StreamExt;
2198
2199    let response =
2200        client.get(url).send().await.map_err(|error| {
2201            PluginError::Registration(format!("failed to fetch '{url}': {error}"))
2202        })?;
2203
2204    if response.status().is_redirection() {
2205        let status = response.status();
2206        // Surface the redirect TARGET (host) so the caller can decide whether
2207        // to trust it / add it to `trusted_hosts` — a redirect with no
2208        // `Location`, or one whose value isn't valid UTF-8, degrades to a
2209        // generic "(unspecified)" rather than failing differently.
2210        let location = response
2211            .headers()
2212            .get(reqwest::header::LOCATION)
2213            .and_then(|value| value.to_str().ok())
2214            .map(str::to_string);
2215        let target = location.as_deref().unwrap_or("(unspecified)");
2216        return Err(PluginError::RedirectRefused(format!(
2217            "refused to follow an HTTP redirect ({status}) from '{url}' to '{target}': for an \
2218             unverified install (no signature, no checksum) the approved host must serve the \
2219             bytes directly, so redirects are not followed — install from the canonical/final \
2220             URL, or provide a signature / `--sha256` (which authenticates the bytes regardless \
2221             of which host serves them), or add the redirect target's host to \
2222             `plugin_trust.trusted_hosts`"
2223        )));
2224    }
2225
2226    let response = response.error_for_status().map_err(|error| {
2227        PluginError::Registration(format!("'{url}' returned an error status: {error}"))
2228    })?;
2229
2230    // Reject up front if the server ADVERTISES an over-cap body (cheap, avoids
2231    // streaming at all)...
2232    if let Some(len) = response.content_length() {
2233        if len > max_bytes {
2234            return Err(PluginError::Registration(format!(
2235                "'{url}' advertises a {len}-byte body, over the {max_bytes}-byte download cap; \
2236                 refusing"
2237            )));
2238        }
2239    }
2240
2241    // ...and ALSO cap the actually-streamed bytes, since Content-Length can be
2242    // absent (chunked) or a lie.
2243    let mut stream = response.bytes_stream();
2244    let mut buffer: Vec<u8> = Vec::new();
2245    while let Some(chunk) = stream.next().await {
2246        let chunk = chunk.map_err(|error| {
2247            PluginError::Registration(format!("failed to read response body of '{url}': {error}"))
2248        })?;
2249        if buffer.len() as u64 + chunk.len() as u64 > max_bytes {
2250            return Err(PluginError::Registration(format!(
2251                "'{url}' streamed more than the {max_bytes}-byte download cap; aborting"
2252            )));
2253        }
2254        buffer.extend_from_slice(&chunk);
2255    }
2256    Ok(buffer)
2257}
2258
2259fn sha256_hex(bytes: &[u8]) -> String {
2260    use sha2::{Digest, Sha256};
2261    let mut hasher = Sha256::new();
2262    hasher.update(bytes);
2263    hex::encode(hasher.finalize())
2264}
2265
2266// ---------------------------------------------------------------------
2267// Archive handling (path-traversal-safe)
2268// ---------------------------------------------------------------------
2269
2270#[derive(Debug, Clone, Copy)]
2271enum ArchiveKind {
2272    Zip,
2273    TarGz,
2274}
2275
2276fn detect_archive_kind(name_or_url: &str) -> Option<ArchiveKind> {
2277    let lower = name_or_url.to_ascii_lowercase();
2278    // Strip a query string/fragment before checking the extension, in case a
2279    // URL looks like `.../plugin.tar.gz?token=...`.
2280    let lower = lower.split(['?', '#']).next().unwrap_or(&lower).to_string();
2281    if lower.ends_with(".zip") {
2282        Some(ArchiveKind::Zip)
2283    } else if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") {
2284        Some(ArchiveKind::TarGz)
2285    } else {
2286        None
2287    }
2288}
2289
2290/// Extract `bytes` (a zip or tar.gz archive) into `dest_dir`, rejecting any
2291/// entry whose path would escape `dest_dir` (traversal / absolute paths), and
2292/// aborting if the TOTAL decompressed output across all entries would exceed
2293/// `max_decompressed_bytes` (decompression-bomb guard — see
2294/// [`MAX_DECOMPRESSED_BYTES`] / [`copy_capped`]). Runs the (synchronous)
2295/// extraction on a blocking thread.
2296async fn extract_archive(
2297    bytes: Vec<u8>,
2298    kind: ArchiveKind,
2299    dest_dir: PathBuf,
2300    max_decompressed_bytes: u64,
2301) -> PluginResult<()> {
2302    tokio::fs::create_dir_all(&dest_dir).await?;
2303    tokio::task::spawn_blocking(move || match kind {
2304        ArchiveKind::Zip => extract_zip_sync(&bytes, &dest_dir, max_decompressed_bytes),
2305        ArchiveKind::TarGz => extract_targz_sync(&bytes, &dest_dir, max_decompressed_bytes),
2306    })
2307    .await
2308    .map_err(|error| {
2309        PluginError::Registration(format!("archive extraction task panicked: {error}"))
2310    })?
2311}
2312
2313/// Copy `reader` into `writer` in small, bounded chunks, tallying bytes into
2314/// `running_total` — which the caller carries ACROSS every entry in the
2315/// archive, so the cap is on the archive's total decompressed output, not
2316/// any one entry — and aborting the moment the cumulative count would exceed
2317/// `max_decompressed_bytes`. Chunked copying (rather than `std::io::copy`
2318/// followed by a size check afterward) keeps the amount ever actually
2319/// written to disk bounded near the cap even for a single maximally
2320/// compressible entry: the whole point of the cap is to stop a small archive
2321/// from exhausting disk, so only checking after a full `io::copy` completed
2322/// would defeat it.
2323fn copy_capped(
2324    reader: &mut impl std::io::Read,
2325    writer: &mut impl std::io::Write,
2326    running_total: &mut u64,
2327    max_decompressed_bytes: u64,
2328) -> PluginResult<()> {
2329    let mut buffer = [0u8; 64 * 1024];
2330    loop {
2331        let bytes_read = reader.read(&mut buffer)?;
2332        if bytes_read == 0 {
2333            return Ok(());
2334        }
2335        *running_total += bytes_read as u64;
2336        if *running_total > max_decompressed_bytes {
2337            return Err(PluginError::InvalidManifest(format!(
2338                "archive expands to more than the {max_decompressed_bytes}-byte decompressed \
2339                 size cap ({running_total} bytes and counting); refusing to unpack (possible \
2340                 decompression bomb)"
2341            )));
2342        }
2343        writer.write_all(&buffer[..bytes_read])?;
2344    }
2345}
2346
2347fn extract_zip_sync(
2348    bytes: &[u8],
2349    dest_dir: &Path,
2350    max_decompressed_bytes: u64,
2351) -> PluginResult<()> {
2352    use std::io::Cursor;
2353
2354    let cursor = Cursor::new(bytes);
2355    let mut archive = zip::ZipArchive::new(cursor)
2356        .map_err(|error| PluginError::InvalidManifest(format!("invalid zip archive: {error}")))?;
2357
2358    let mut total_decompressed_bytes: u64 = 0;
2359
2360    for index in 0..archive.len() {
2361        let mut file = archive.by_index(index).map_err(|error| {
2362            PluginError::InvalidManifest(format!("invalid zip entry at index {index}: {error}"))
2363        })?;
2364        // `enclosed_name()` is the zip crate's own traversal guard: it
2365        // returns `None` for any entry whose name contains `..`, is
2366        // absolute, or otherwise can't be safely joined under `dest_dir`.
2367        let Some(relative_path) = file.enclosed_name() else {
2368            return Err(PluginError::InvalidManifest(format!(
2369                "zip entry '{}' has an unsafe path (traversal/absolute) — refusing to unpack",
2370                file.name()
2371            )));
2372        };
2373        let out_path = dest_dir.join(&relative_path);
2374        if file.is_dir() {
2375            std::fs::create_dir_all(&out_path)?;
2376            continue;
2377        }
2378        if let Some(parent) = out_path.parent() {
2379            std::fs::create_dir_all(parent)?;
2380        }
2381        let mut out_file = std::fs::File::create(&out_path)?;
2382        if let Err(error) = copy_capped(
2383            &mut file,
2384            &mut out_file,
2385            &mut total_decompressed_bytes,
2386            max_decompressed_bytes,
2387        ) {
2388            drop(out_file);
2389            // Defense in depth: remove the partial file we were just writing
2390            // even though the source transaction also wipes the whole
2391            // staging directory on any `Err` from this function — a
2392            // direct caller of this lower-level helper should never see a
2393            // half-written entry either.
2394            let _ = std::fs::remove_file(&out_path);
2395            return Err(error);
2396        }
2397        drop(out_file);
2398
2399        #[cfg(unix)]
2400        {
2401            use std::os::unix::fs::PermissionsExt;
2402            if let Some(mode) = file.unix_mode() {
2403                std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))?;
2404            }
2405        }
2406    }
2407    Ok(())
2408}
2409
2410fn extract_targz_sync(
2411    bytes: &[u8],
2412    dest_dir: &Path,
2413    max_decompressed_bytes: u64,
2414) -> PluginResult<()> {
2415    use flate2::read::GzDecoder;
2416    use std::path::Component;
2417    use tar::{Archive, EntryType};
2418
2419    let decoder = GzDecoder::new(bytes);
2420    let mut archive = Archive::new(decoder);
2421    let mut total_decompressed_bytes: u64 = 0;
2422    for entry_result in archive.entries()? {
2423        let mut entry = entry_result?;
2424
2425        // SECURITY (symlink/hardlink escape): reject any Symlink or HardLink
2426        // entry outright BEFORE unpacking. `entry.unpack()` is the raw tar API
2427        // — it validates the entry's OWN path (checked below) but NOT a link
2428        // entry's TARGET (`link_name`), which is fully attacker-controlled and
2429        // may be absolute or contain `..`. A malicious bundle could ship
2430        // e.g. `workflows/evil.md` as a symlink to `~/.ssh/id_rsa` or bamboo's
2431        // `config.json`; a later `fs::read_to_string` (register_workflows) would
2432        // follow it and copy the victim's real content into a plugin-visible
2433        // location = arbitrary file exfiltration, and `flatten_if_single_subdir`
2434        // following a symlink-to-a-real-dir could rename/destroy the victim's
2435        // files. A plugin bundle has no legitimate reason to ship a link (same
2436        // rationale as `copy_dir_recursive` skipping symlinks), so refuse the
2437        // whole archive. (Zip is not affected: `extract_zip_sync` writes every
2438        // entry as a fresh regular file via `copy_capped`, so an archived
2439        // "symlink" lands inert as a plain file.)
2440        let entry_type = entry.header().entry_type();
2441        if matches!(entry_type, EntryType::Symlink | EntryType::Link) {
2442            let link_target = entry
2443                .link_name()
2444                .ok()
2445                .flatten()
2446                .map(|path| path.display().to_string())
2447                .unwrap_or_default();
2448            return Err(PluginError::InvalidManifest(format!(
2449                "tar entry '{}' is a {} (target '{link_target}') — plugin bundles must not ship \
2450                 links; refusing to unpack",
2451                entry
2452                    .path()
2453                    .map(|p| p.display().to_string())
2454                    .unwrap_or_default(),
2455                if entry_type == EntryType::Symlink {
2456                    "symlink"
2457                } else {
2458                    "hardlink"
2459                },
2460            )));
2461        }
2462
2463        let relative_path = entry.path()?.into_owned();
2464        let is_unsafe = relative_path.components().any(|component| {
2465            matches!(
2466                component,
2467                Component::ParentDir | Component::RootDir | Component::Prefix(_)
2468            )
2469        });
2470        if is_unsafe {
2471            return Err(PluginError::InvalidManifest(format!(
2472                "tar entry '{}' has an unsafe path (traversal/absolute) — refusing to unpack",
2473                relative_path.display()
2474            )));
2475        }
2476        let out_path = dest_dir.join(&relative_path);
2477
2478        // Directories carry no content to cap — just create and move on
2479        // (mirrors `entry.unpack()`'s own directory handling, which this
2480        // function replaces for content-bearing entries below so the
2481        // decompressed-size cap can be enforced incrementally; see
2482        // `copy_capped`).
2483        if entry_type.is_dir() {
2484            std::fs::create_dir_all(&out_path)?;
2485            continue;
2486        }
2487
2488        if let Some(parent) = out_path.parent() {
2489            std::fs::create_dir_all(parent)?;
2490        }
2491        let mut out_file = std::fs::File::create(&out_path)?;
2492        if let Err(error) = copy_capped(
2493            &mut entry,
2494            &mut out_file,
2495            &mut total_decompressed_bytes,
2496            max_decompressed_bytes,
2497        ) {
2498            drop(out_file);
2499            // Defense in depth: see the identical cleanup in
2500            // `extract_zip_sync` — the whole staging dir is also wiped by
2501            // the caller, but a direct caller of this helper shouldn't see
2502            // a half-written entry either.
2503            let _ = std::fs::remove_file(&out_path);
2504            return Err(error);
2505        }
2506        drop(out_file);
2507
2508        // Preserve the entry's permission bits (matches `entry.unpack()`'s
2509        // own behaviour, which this manual copy replaces).
2510        #[cfg(unix)]
2511        {
2512            use std::os::unix::fs::PermissionsExt;
2513            if let Ok(mode) = entry.header().mode() {
2514                std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))?;
2515            }
2516        }
2517    }
2518    Ok(())
2519}
2520
2521// ---------------------------------------------------------------------
2522// Filesystem helpers
2523// ---------------------------------------------------------------------
2524
2525async fn read_and_parse_manifest(dir: &Path) -> PluginResult<PluginManifest> {
2526    let manifest_path = dir.join("plugin.json");
2527    let raw = tokio::fs::read_to_string(&manifest_path)
2528        .await
2529        .map_err(|_| {
2530            PluginError::InvalidManifest(format!(
2531                "no plugin.json found at '{}'",
2532                manifest_path.display()
2533            ))
2534        })?;
2535    PluginManifest::parse_str(&raw)
2536}
2537
2538/// If `dir` has no `plugin.json` of its own but contains EXACTLY one
2539/// subdirectory, move that subdirectory's contents up into `dir` (the common
2540/// `tar czf bundle.tar.gz plugin-name/`-style archive convention). A no-op if
2541/// `plugin.json` is already present, or if the shape doesn't match (multiple
2542/// top-level entries, or a single top-level entry that isn't a directory) —
2543/// in either case, [`read_and_parse_manifest`] will simply fail to find
2544/// `plugin.json` afterwards with a clear error.
2545async fn flatten_if_single_subdir(dir: &Path) -> PluginResult<()> {
2546    if tokio::fs::try_exists(dir.join("plugin.json"))
2547        .await
2548        .unwrap_or(false)
2549    {
2550        return Ok(());
2551    }
2552
2553    let mut entries = tokio::fs::read_dir(dir).await?;
2554    let mut only_entry: Option<PathBuf> = None;
2555    let mut count = 0usize;
2556    while let Some(entry) = entries.next_entry().await? {
2557        count += 1;
2558        if count > 1 {
2559            return Ok(());
2560        }
2561        only_entry = Some(entry.path());
2562    }
2563    let Some(candidate) = only_entry else {
2564        return Ok(());
2565    };
2566    // `symlink_metadata` (does NOT follow links), not `metadata`: defense in
2567    // depth so a symlink-to-a-real-dir can never pass `is_dir()` here and get
2568    // its real children renamed out. Extraction already rejects link entries
2569    // (see `extract_targz_sync`) and `copy_dir_recursive` skips symlinks, so
2570    // in practice `candidate` is always a real dir/file — but never follow a
2571    // link when deciding whether to descend into and move a directory's
2572    // contents.
2573    if !tokio::fs::symlink_metadata(&candidate).await?.is_dir() {
2574        return Ok(());
2575    }
2576
2577    // Move `candidate`'s children up into `dir`, then remove the now-empty
2578    // `candidate` directory.
2579    let mut children = tokio::fs::read_dir(&candidate).await?;
2580    while let Some(child) = children.next_entry().await? {
2581        let dest = dir.join(child.file_name());
2582        tokio::fs::rename(child.path(), dest).await?;
2583    }
2584    tokio::fs::remove_dir(&candidate).await?;
2585    Ok(())
2586}
2587
2588/// Recursively copy `source` into `dest` (creating `dest`).
2589fn copy_dir_recursive<'a>(
2590    source: &'a Path,
2591    dest: &'a Path,
2592) -> std::pin::Pin<Box<dyn std::future::Future<Output = PluginResult<()>> + Send + 'a>> {
2593    Box::pin(async move {
2594        tokio::fs::create_dir_all(dest).await?;
2595        let mut entries = tokio::fs::read_dir(source).await?;
2596        while let Some(entry) = entries.next_entry().await? {
2597            let file_type = entry.file_type().await?;
2598            let dest_path = dest.join(entry.file_name());
2599            if file_type.is_dir() {
2600                copy_dir_recursive(&entry.path(), &dest_path).await?;
2601            } else if file_type.is_file() {
2602                tokio::fs::copy(entry.path(), &dest_path).await?;
2603            }
2604            // Symlinks are intentionally skipped — a plugin bundle has no
2605            // legitimate reason to ship one, and following it could escape
2606            // the source directory.
2607        }
2608        Ok(())
2609    })
2610}
2611
2612#[cfg(test)]
2613mod tests;