Skip to main content

bamboo_plugin/
error.rs

1//! Error type shared by manifest validation, provenance I/O, and the
2//! installer trait.
3
4/// Result alias used throughout this crate.
5pub type PluginResult<T> = Result<T, PluginError>;
6
7#[derive(Debug, thiserror::Error)]
8pub enum PluginError {
9    /// `plugin.json` failed structural validation (see
10    /// [`crate::manifest::PluginManifest::validate`]) — includes bad ids,
11    /// bad semver shape, path-traversal attempts, duplicate ids, etc.
12    #[error("invalid plugin manifest: {0}")]
13    InvalidManifest(String),
14
15    /// No installed plugin with this id (uninstall/lookup).
16    #[error("plugin not found: {0}")]
17    NotFound(String),
18
19    /// A plugin with this id is already installed and `install` was called
20    /// with [`crate::installer::InstallDisposition::FailIfInstalled`] (the
21    /// `bamboo plugin install` verb). Re-run as an upgrade
22    /// (`bamboo plugin update` / [`crate::installer::InstallDisposition::Upgrade`])
23    /// to replace it in place.
24    #[error("plugin already installed: {0} (use `update` to upgrade in place)")]
25    AlreadyInstalled(String),
26
27    /// A capability the plugin declares collides with an existing entry in a
28    /// shared store that is NOT owned by this plugin (a user's own entry, or
29    /// another plugin's). For MCP servers and workflows the install REFUSES
30    /// rather than clobbering — an MCP server id / workflow filename is
31    /// referenced elsewhere, so silently overwriting (and later deleting on
32    /// uninstall) would destroy the user's entry. `kind` is a short label
33    /// such as `"mcp server"` or `"workflow"`.
34    #[error(
35        "{kind} '{name}' already exists and is not owned by plugin '{plugin_id}'; \
36         refusing to overwrite — rename the conflicting entry or the plugin's, then retry"
37    )]
38    Conflict {
39        kind: &'static str,
40        name: String,
41        plugin_id: String,
42    },
43
44    /// The manifest's `platforms` gate excludes the current OS.
45    #[error("plugin '{plugin_id}' does not support platform '{platform}'")]
46    UnsupportedPlatform { plugin_id: String, platform: String },
47
48    /// A step this foundation crate deliberately leaves for a later agent
49    /// (capability-registration wiring — see `PLUGIN_PLAN.md`). Returned
50    /// instead of panicking so a partially-stacked branch fails a request
51    /// cleanly rather than crashing the process.
52    #[error("not yet implemented: {0}")]
53    NotImplemented(String),
54
55    /// A capability failed to register/deregister against `AppState` for a
56    /// reason OTHER than an ownership conflict (e.g. `config.json` couldn't
57    /// be persisted, a network fetch during source-staging failed). Kept
58    /// distinct from [`Self::Conflict`] (a deliberate REFUSAL, not a
59    /// failure) so callers/HTTP status mapping can tell "your plugin
60    /// collides with something" apart from "something broke while trying to
61    /// register/fetch it".
62    #[error("plugin registration failed: {0}")]
63    Registration(String),
64
65    /// A downloaded artifact's sha256 did not match the manifest's declared
66    /// hash. Checked BEFORE unpacking (supply-chain: a URL-installed plugin
67    /// ships a binary that will be executed) — never surfaced as a generic
68    /// `Registration`/`InvalidManifest` error so callers can distinguish "the
69    /// author's manifest is malformed" from "the bytes served at that URL do
70    /// not match what the manifest promised".
71    #[error("artifact verification failed: {0}")]
72    ArtifactVerificationFailed(String),
73
74    /// A downloaded URL plugin BUNDLE (the `plugin.json` or the archive
75    /// containing it — whatever the app layer's URL-source fetch downloads)
76    /// did not hash to the caller-supplied expected sha256. Checked BEFORE
77    /// any extraction/parsing, so a tampered bundle is never trusted even
78    /// partially. Distinct from [`Self::ArtifactVerificationFailed`] (which
79    /// is pinned by a hash declared INSIDE the manifest — itself only
80    /// trustworthy once the bundle carrying it is verified): this is the
81    /// root-of-trust check that closes the circular-trust hole where the
82    /// manifest's own artifact hash could be rewritten by whoever tampered
83    /// with the bundle.
84    #[error("bundle verification failed: {0}")]
85    BundleVerificationFailed(String),
86
87    /// A URL plugin install/update was requested with neither a `sha256` to
88    /// verify the downloaded bundle against, nor an explicit
89    /// `allow_unverified` opt-in. This is the secure-by-default refusal: a
90    /// URL install must be either checksum-pinned or an explicit,
91    /// deliberate risk acceptance — never a silent "download and trust any
92    /// tar.gz". Raised BEFORE the URL is ever fetched (no network access
93    /// happens for a refused install).
94    #[error("{0}")]
95    ChecksumRequired(String),
96
97    /// A URL plugin install/update's host (and path) is not in the
98    /// configured `plugin_trust.trusted_hosts` allowlist, and the request did
99    /// not set `allow_untrusted_host`. This is the SOURCE-authorization
100    /// layer (host allowlist) — distinct from [`Self::BundleVerificationFailed`]
101    /// (integrity) and [`Self::UnsignedOrUntrustedSignature`] (publisher
102    /// authenticity). Raised BEFORE the URL is ever fetched, same posture as
103    /// [`Self::ChecksumRequired`].
104    #[error("{0}")]
105    UntrustedHost(String),
106
107    /// A URL plugin bundle is unsigned, or its `.sig` sidecar does not verify
108    /// against any key in `plugin_trust.trusted_keys`, and the request did
109    /// not set `allow_unsigned`. This is the PUBLISHER-authenticity layer —
110    /// a signature proves who produced the bytes, which a bare sha256 (only
111    /// proving WHAT the bytes are) cannot. Raised after the bundle is
112    /// downloaded (the signature is verified over the downloaded bytes) but
113    /// before anything is extracted/parsed.
114    #[error("{0}")]
115    UnsignedOrUntrustedSignature(String),
116
117    /// A URL plugin install whose bytes will NOT be cryptographically
118    /// authenticated (no signature required AND no `sha256` — the fully
119    /// opted-out `allow_unsigned && allow_unverified`, "host-only trust"
120    /// case) was served an HTTP redirect instead of the bytes. In that case
121    /// the host allowlist is the SOLE control over where the bytes come
122    /// from, and it only vetted the FIRST hop — transparently following the
123    /// redirect would let the bytes come from an unvetted host and silently
124    /// defeat the allowlist, so redirects are not followed and a redirect
125    /// response is refused. A trust/authorization refusal (same 403 family
126    /// as [`Self::UntrustedHost`] / [`Self::UnsignedOrUntrustedSignature`]),
127    /// NOT a server error — it tells the caller how to proceed (install from
128    /// the canonical/final URL, provide a signature/`--sha256`, or trust the
129    /// redirect target). Does not arise once a signature or checksum is in
130    /// play: those authenticate the bytes regardless of which host served
131    /// them, so redirects are followed in that case.
132    #[error("{0}")]
133    RedirectRefused(String),
134
135    #[error("io error: {0}")]
136    Io(#[from] std::io::Error),
137
138    #[error("json error: {0}")]
139    Json(#[from] serde_json::Error),
140}