plecto_host/errors.rs
1//! Error types surfaced by the host: [`RunError`] (a per-request filter-call failure) and
2//! [`LoadError`] (why `Host::load` rejected a filter), plus the SBOM↔component binding check.
3
4use sha2::{Digest, Sha256};
5
6use crate::{Header, HttpResponse};
7
8/// Why a per-request filter call did not produce a `decision`. Kept deliberately distinct
9/// from `RequestDecision`/`ResponseDecision` — those are the filter's *intentional* typed
10/// output; a `RunError` is the filter *failing*. The fast path MUST fail-closed on it:
11/// synthesise an error response and never forward to upstream (CLAUDE.md — no fail-open).
12/// Keeping the two apart also makes "deadline" vs "trap" an observable health signal.
13#[derive(Debug, thiserror::Error)]
14pub enum RunError {
15 /// The filter ran past its epoch deadline (ADR 000006 metering) and was interrupted.
16 /// Fail-closed mapping: 504.
17 #[error("filter exceeded its epoch deadline")]
18 Deadline,
19 /// The filter trapped (`unreachable`, a guest panic, or an allocation past the Store
20 /// memory limit that aborted the guest). Fail-closed mapping: 502.
21 #[error("filter trapped: {0}")]
22 Trap(anyhow::Error),
23 /// A fresh instance could not be created — untrusted per-request instantiation, or the
24 /// rebuild of a trusted instance after a prior trap. Fail-closed mapping: 502.
25 #[error("filter instantiation failed: {0}")]
26 Instantiate(anyhow::Error),
27 /// A trusted filter trapped on several consecutive requests, so the host is in a short
28 /// trap-cooldown: it returns this cheap fail-closed response instead of re-instantiating +
29 /// re-init'ing every request (circuit-breaker, review f000003 #5). Fail-closed mapping: 503.
30 #[error("filter is in trap-cooldown (circuit open)")]
31 Unavailable,
32 /// The guest returned cleanly but its output violates contract rules (CRLF / CTL / non-tchar
33 /// header, oversize header, or oversize synthesised body — ADR 000071 / 000073). Distinct
34 /// from `Trap` so operators can tell a misbehaving-but-alive filter from a crashing one;
35 /// fail-closed mapping is 502.
36 #[error("filter returned invalid output (malformed header or oversize synthesised body)")]
37 InvalidOutput,
38}
39
40/// Marker the runtime wraps in a `wasmtime::Error` when guest output fails validation, so
41/// [`RunError::from_call`] can classify it as [`RunError::InvalidOutput`] instead of a trap.
42#[derive(Debug, thiserror::Error)]
43#[error("filter returned invalid output; failing closed")]
44pub(crate) struct InvalidGuestOutput;
45
46impl RunError {
47 /// Classify the error from a guest call: an epoch interrupt is a `Deadline`, a validation
48 /// marker is `InvalidOutput`, anything else is a `Trap`. (`wasmtime 45` returns its own
49 /// `wasmtime::Error`, distinct from `anyhow::Error`; we convert into `anyhow::Error` for
50 /// storage.)
51 pub(crate) fn from_call(e: wasmtime::Error) -> Self {
52 if e.downcast_ref::<InvalidGuestOutput>().is_some() {
53 return RunError::InvalidOutput;
54 }
55 match e.downcast_ref::<wasmtime::Trap>() {
56 Some(wasmtime::Trap::Interrupt) => RunError::Deadline,
57 _ => RunError::Trap(anyhow::Error::from(e)),
58 }
59 }
60
61 /// A synthetic, fail-closed response for this fault (host helper; the fast path may send
62 /// it directly). Deadline → 504, every other fault → 502. Never a pass-through.
63 pub fn fail_closed_response(&self) -> HttpResponse {
64 let (status, fault, msg): (u16, &str, &str) = match self {
65 RunError::Deadline => (504, "deadline", "filter deadline exceeded"),
66 RunError::Trap(_) => (502, "trap", "filter trapped"),
67 RunError::Instantiate(_) => (502, "instantiate", "filter instantiation failed"),
68 RunError::Unavailable => (503, "unavailable", "filter temporarily unavailable"),
69 RunError::InvalidOutput => (502, "invalid-output", "filter returned invalid output"),
70 };
71 HttpResponse {
72 status,
73 headers: vec![Header {
74 name: "x-plecto-fault".to_string(),
75 value: fault.to_string().into_bytes(),
76 }],
77 body: msg.as_bytes().to_vec(),
78 }
79 }
80}
81
82/// Why [`Host::load`] rejected a filter (bp-rust: typed library errors, not ad hoc
83/// `anyhow::ensure!`). Every variant is a fail-closed rejection at the provenance/id gate, before
84/// wasmtime ever touches the component bytes — except [`LoadError::Instantiate`] /
85/// [`LoadError::Wasmtime`], which surface a failure from wasmtime itself (linking / type-checking
86/// / the eager trusted-instance build). `Host::load`'s public signature stays `anyhow::Result`
87/// (unchanged, so `plecto-control::ControlError::Load`'s existing `anyhow::Error` passthrough
88/// keeps working); callers that want the concrete variant can `downcast_ref::<LoadError>()`.
89#[derive(Debug, thiserror::Error)]
90pub enum LoadError {
91 #[error("filter id must be non-empty")]
92 EmptyFilterId,
93 #[error("filter id must not contain the KV namespace delimiter")]
94 FilterIdContainsDelimiter,
95 #[error("a signed SBOM is required to load a filter (fail-closed; ADR 000006)")]
96 MissingSbom,
97 #[error("component signature is not verified by any trusted key (fail-closed; ADR 000006)")]
98 UnverifiedComponentSignature,
99 #[error("SBOM signature is not verified by any trusted key (fail-closed; ADR 000006)")]
100 UnverifiedSbomSignature,
101 #[error("SBOM is not a valid in-toto statement: {0}")]
102 MalformedSbom(#[source] serde_json::Error),
103 #[error(
104 "SBOM does not attest this component: no subject digest matches sha256(component) \
105 (fail-closed; ADR 000006 / review f000003)"
106 )]
107 SbomNotBound,
108 /// The component's decoded imports name no recognised `plecto:filter@0.N` track (absent, or
109 /// a future version the host does not yet bind). Fail-closed at load — never guess V03.
110 #[error(
111 "component does not import a recognised plecto:filter contract version \
112 (need @0.1 / @0.2 / @0.3; fail-closed)"
113 )]
114 UnsupportedContractVersion,
115 /// The eager trusted-instance build (`Host::load`'s `Isolation::Trusted` path) failed —
116 /// carries the same error `RunError::Instantiate` would for a later rebuild.
117 #[error("filter instantiation failed: {0}")]
118 Instantiate(anyhow::Error),
119 #[error(transparent)]
120 Wasmtime(#[from] wasmtime::Error),
121}
122
123/// Verify the SBOM attests THIS component: parse it as an in-toto-style statement and require
124/// at least one `subject[].digest.sha256` to equal `sha256(component)`. Fail-closed on a
125/// malformed SBOM or a missing / mismatched subject (review f000003 #1). Without this, a
126/// validly-signed but UNRELATED SBOM could be paired with the component — harmless while the
127/// SBOM is opaque, a latent gap the moment its content becomes load-bearing (CVE / license).
128pub(crate) fn sbom_binds_component(
129 sbom: &[u8],
130 component: &[u8],
131) -> std::result::Result<(), LoadError> {
132 let statement: serde_json::Value =
133 serde_json::from_slice(sbom).map_err(LoadError::MalformedSbom)?;
134 let want = hex::encode(Sha256::digest(component));
135 let bound = statement
136 .get("subject")
137 .and_then(|s| s.as_array())
138 .is_some_and(|subjects| {
139 subjects.iter().any(|subject| {
140 subject
141 .get("digest")
142 .and_then(|d| d.get("sha256"))
143 .and_then(|h| h.as_str())
144 == Some(want.as_str())
145 })
146 });
147 if bound {
148 Ok(())
149 } else {
150 Err(LoadError::SbomNotBound)
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn run_error_maps_to_fail_closed_response() {
160 // The host's synthetic responses are fail-closed (5xx), never a pass-through, and
161 // distinguish a deadline (504) from any other trap (502) for observability (ADR 000006).
162 let deadline = RunError::Deadline.fail_closed_response();
163 assert_eq!(deadline.status, 504);
164 assert!(
165 deadline
166 .headers
167 .iter()
168 .any(|h| h.name == "x-plecto-fault" && h.value.as_slice() == b"deadline")
169 );
170
171 let trap = RunError::Trap(anyhow::anyhow!("boom")).fail_closed_response();
172 assert_eq!(trap.status, 502);
173 assert!(
174 trap.headers
175 .iter()
176 .any(|h| h.name == "x-plecto-fault" && h.value.as_slice() == b"trap")
177 );
178 }
179
180 #[test]
181 fn invalid_guest_output_is_classified_apart_from_a_trap() {
182 // A guest that returns cleanly but violates the header rules (ADR 000071) must be
183 // observable as `invalid-output`, not conflated with a crash.
184 let e = wasmtime::Error::new(InvalidGuestOutput);
185 assert!(matches!(RunError::from_call(e), RunError::InvalidOutput));
186
187 let resp = RunError::InvalidOutput.fail_closed_response();
188 assert_eq!(resp.status, 502);
189 assert!(
190 resp.headers
191 .iter()
192 .any(|h| h.name == "x-plecto-fault" && h.value.as_slice() == b"invalid-output")
193 );
194 }
195}