use sha2::{Digest, Sha256};
use crate::{Header, HttpResponse};
#[derive(Debug, thiserror::Error)]
pub enum RunError {
#[error("filter exceeded its epoch deadline")]
Deadline,
#[error("filter trapped: {0}")]
Trap(anyhow::Error),
#[error("filter instantiation failed: {0}")]
Instantiate(anyhow::Error),
#[error("filter is in trap-cooldown (circuit open)")]
Unavailable,
#[error("filter returned invalid output (malformed header or oversize synthesised body)")]
InvalidOutput,
}
#[derive(Debug, thiserror::Error)]
#[error("filter returned invalid output; failing closed")]
pub(crate) struct InvalidGuestOutput;
impl RunError {
pub(crate) fn from_call(e: wasmtime::Error) -> Self {
if e.downcast_ref::<InvalidGuestOutput>().is_some() {
return RunError::InvalidOutput;
}
match e.downcast_ref::<wasmtime::Trap>() {
Some(wasmtime::Trap::Interrupt) => RunError::Deadline,
_ => RunError::Trap(anyhow::Error::from(e)),
}
}
pub fn fail_closed_response(&self) -> HttpResponse {
let (status, fault, msg): (u16, &str, &str) = match self {
RunError::Deadline => (504, "deadline", "filter deadline exceeded"),
RunError::Trap(_) => (502, "trap", "filter trapped"),
RunError::Instantiate(_) => (502, "instantiate", "filter instantiation failed"),
RunError::Unavailable => (503, "unavailable", "filter temporarily unavailable"),
RunError::InvalidOutput => (502, "invalid-output", "filter returned invalid output"),
};
HttpResponse {
status,
headers: vec![Header {
name: "x-plecto-fault".to_string(),
value: fault.to_string().into_bytes(),
}],
body: msg.as_bytes().to_vec(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum LoadError {
#[error("filter id must be non-empty")]
EmptyFilterId,
#[error("filter id must not contain the KV namespace delimiter")]
FilterIdContainsDelimiter,
#[error("a signed SBOM is required to load a filter (fail-closed; ADR 000006)")]
MissingSbom,
#[error("component signature is not verified by any trusted key (fail-closed; ADR 000006)")]
UnverifiedComponentSignature,
#[error("SBOM signature is not verified by any trusted key (fail-closed; ADR 000006)")]
UnverifiedSbomSignature,
#[error("SBOM is not a valid in-toto statement: {0}")]
MalformedSbom(#[source] serde_json::Error),
#[error(
"SBOM does not attest this component: no subject digest matches sha256(component) \
(fail-closed; ADR 000006 / review f000003)"
)]
SbomNotBound,
#[error(
"component does not import a recognised plecto:filter contract version \
(need @0.1 / @0.2 / @0.3; fail-closed)"
)]
UnsupportedContractVersion,
#[error("filter instantiation failed: {0}")]
Instantiate(anyhow::Error),
#[error(transparent)]
Wasmtime(#[from] wasmtime::Error),
}
pub(crate) fn sbom_binds_component(
sbom: &[u8],
component: &[u8],
) -> std::result::Result<(), LoadError> {
let statement: serde_json::Value =
serde_json::from_slice(sbom).map_err(LoadError::MalformedSbom)?;
let want = hex::encode(Sha256::digest(component));
let bound = statement
.get("subject")
.and_then(|s| s.as_array())
.is_some_and(|subjects| {
subjects.iter().any(|subject| {
subject
.get("digest")
.and_then(|d| d.get("sha256"))
.and_then(|h| h.as_str())
== Some(want.as_str())
})
});
if bound {
Ok(())
} else {
Err(LoadError::SbomNotBound)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_error_maps_to_fail_closed_response() {
let deadline = RunError::Deadline.fail_closed_response();
assert_eq!(deadline.status, 504);
assert!(
deadline
.headers
.iter()
.any(|h| h.name == "x-plecto-fault" && h.value.as_slice() == b"deadline")
);
let trap = RunError::Trap(anyhow::anyhow!("boom")).fail_closed_response();
assert_eq!(trap.status, 502);
assert!(
trap.headers
.iter()
.any(|h| h.name == "x-plecto-fault" && h.value.as_slice() == b"trap")
);
}
#[test]
fn invalid_guest_output_is_classified_apart_from_a_trap() {
let e = wasmtime::Error::new(InvalidGuestOutput);
assert!(matches!(RunError::from_call(e), RunError::InvalidOutput));
let resp = RunError::InvalidOutput.fail_closed_response();
assert_eq!(resp.status, 502);
assert!(
resp.headers
.iter()
.any(|h| h.name == "x-plecto-fault" && h.value.as_slice() == b"invalid-output")
);
}
}