Skip to main content

plecto_control/
error.rs

1//! Typed control-plane errors (bp-rust: domain errors are `thiserror` enums, not `anyhow`).
2//! A caller can tell a config mistake (`ManifestParse`, `UnknownChainFilter`) apart from a
3//! supply-chain failure (`DigestMismatch`, `Load`) — both are fail-closed, but distinguishable.
4
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8pub enum ControlError {
9    #[error("manifest parse error: {0}")]
10    ManifestParse(#[from] toml::de::Error),
11
12    /// The manifest could not be canonicalised for content-hashing. Serialising our own
13    /// derived types is infallible in practice; a typed variant keeps `content_hash`
14    /// panic-free (bp-rust: no `expect` outside binary entry points).
15    #[error("manifest serialisation failed: {0}")]
16    ManifestSerialize(#[from] serde_json::Error),
17
18    /// `reload_from_disk` was called on a `Control` built from an in-memory manifest (no
19    /// backing path). Construct with `from_manifest_path` / `load_at` to enable disk reload.
20    #[error("control plane has no manifest path on disk to reload from")]
21    NoManifestPath,
22
23    /// A reload's manifest changed the `[trust]` section. Trust roots are fixed at
24    /// construction (same `Host`, same epoch ticker — ADR 000006 / 000008); a reload only
25    /// swaps the filter set + chain, never the trust policy. Rejecting the change fail-closed
26    /// (rather than silently ignoring it) keeps an operator from believing a key rotation took
27    /// effect when it did not — rotate trust by restarting with the new manifest.
28    #[error("manifest [trust] changed; trust roots are fixed at construction — restart to apply")]
29    TrustChangeRequiresRestart,
30
31    /// A reload's manifest changed the `[state]` section. The state backend is fixed at
32    /// construction like the trust roots (ADR 000041): the `Host` holds one `KvBackend` for
33    /// its life, so a backend/path edit cannot take effect on a reload. Rejecting it
34    /// fail-closed keeps an operator from believing a durability change took effect when it
35    /// did not — change the backend by restarting with the new manifest.
36    #[error(
37        "manifest [state] changed; the state backend is fixed at construction — restart to apply"
38    )]
39    StateChangeRequiresRestart,
40
41    /// The `[state]` section is inconsistent (ADR 000041): `redb` without a `path`, or a
42    /// `path` under `memory`. Rejected fail-closed so a half-edited section never silently
43    /// runs on memory while the operator believes state is durable.
44    #[error("invalid [state] config: {0}")]
45    InvalidStateConfig(String),
46
47    /// The configured state backend could not be constructed (ADR 000041): the redb file's
48    /// parent directory is missing, or redb failed to open/create the database.
49    #[error("state backend init failed: {0}")]
50    StateBackendInit(String),
51
52    /// The `[listen]` section is inconsistent (ADR 000057): a `[listen.proxy_protocol]` with an
53    /// empty or unparseable `trusted` CIDR list. Rejected fail-closed — enabling PROXY v2
54    /// without declaring who may speak it would mean trusting every peer (deny-by-default, P4).
55    #[error("invalid [listen] config: {0}")]
56    InvalidListenConfig(String),
57
58    #[error("i/o error: {0}")]
59    Io(#[from] std::io::Error),
60
61    /// An I/O failure carrying the file it was for: a bare `Io` ("No such file or directory
62    /// (os error 2)") gives the operator no hint WHICH referenced file — the manifest, a trust
63    /// key, a state dir — was the problem. Preferred over `Io` wherever the path is known.
64    #[error("i/o error on {path}: {source}", path = .path.display())]
65    IoAt {
66        path: std::path::PathBuf,
67        #[source]
68        source: std::io::Error,
69    },
70
71    #[error("trusted key error: {0}")]
72    TrustKey(String),
73
74    #[error("host initialisation failed: {0}")]
75    HostInit(String),
76
77    /// The artifact could not be resolved or was malformed (missing layer, bad layout, …).
78    #[error("artifact {source_ref:?}: {reason}")]
79    Artifact { source_ref: String, reason: String },
80
81    /// The resolved artifact's content digest did not equal the manifest's pin (ADR 000007
82    /// reproducibility / supply-chain integrity). Fail-closed.
83    #[error(
84        "content digest mismatch for {source_ref:?}: manifest pinned {expected}, artifact is {actual}"
85    )]
86    DigestMismatch {
87        source_ref: String,
88        expected: String,
89        actual: String,
90    },
91
92    /// The host rejected the filter at the provenance/load gate (ADR 000006 signature/SBOM,
93    /// or instantiation). Carries the host's `anyhow` error for its message.
94    #[error("filter {id:?} failed the load gate: {err}")]
95    Load { id: String, err: anyhow::Error },
96
97    /// A filter entry carried an out-of-range metering / rate-limit value (e.g. a zero deadline,
98    /// zero memory cap, or a rate-limit bucket that can never refill). Rejected fail-closed at
99    /// build so a config typo cannot reach the host's metering arithmetic (CWE-20).
100    #[error("filter {id:?} has invalid config: {reason}")]
101    InvalidFilterConfig { id: String, reason: String },
102
103    #[error("chain references unknown filter {0:?}")]
104    UnknownChainFilter(String),
105
106    #[error("duplicate filter id {0:?} in manifest")]
107    DuplicateFilterId(String),
108
109    #[error("duplicate upstream name {0:?} in manifest")]
110    DuplicateUpstream(String),
111
112    /// An upstream declared no `addresses` (ADR 000017). An upstream must have at least one
113    /// instance to forward to; an empty list is fail-closed at build time, not at request time.
114    #[error("upstream {0:?} has no addresses")]
115    EmptyUpstreamAddresses(String),
116
117    /// The upstream registry's lock was poisoned (a thread panicked while holding it). Surfaced
118    /// rather than re-panicked so a reconcile fails closed (the running set stays live).
119    #[error("upstream registry lock poisoned")]
120    UpstreamRegistryPoisoned,
121
122    /// An upstream's load-balancing config (ADR 000035) was malformed: a per-instance `weight` of
123    /// zero or over the cap, a `maglev` upstream without (or a non-`maglev` upstream with) a
124    /// `[upstream.hash]` block, a `header` hash key with no header name, a non-prime / out-of-range
125    /// `table_size`, or more instances than the maglev table can index. Rejected fail-closed at
126    /// build, before the persistent registry mutates, so a bad LB config never reaches the hot path.
127    #[error("upstream {name:?} has invalid load-balancing config: {reason}")]
128    InvalidUpstreamLb { name: String, reason: String },
129
130    #[error("route (prefix {path_prefix:?}) references unknown upstream {upstream:?}")]
131    UnknownRouteUpstream {
132        path_prefix: String,
133        upstream: String,
134    },
135
136    #[error("route (prefix {path_prefix:?}) references unknown filter {filter:?}")]
137    UnknownRouteFilter { path_prefix: String, filter: String },
138
139    /// A route's forwarding target or weighted traffic split (ADR 000034) was malformed: both or
140    /// neither of `upstream` / `backends` set, an empty `backends`, every weight zero, a weight
141    /// over the cap, or a reduced split table too large. Rejected fail-closed at build, before the
142    /// upstream registry reconciles, so a bad split never mutates persistent state.
143    #[error("route (prefix {path_prefix:?}) has an invalid traffic split: {reason}")]
144    InvalidRoute { path_prefix: String, reason: String },
145
146    /// A route's native rate limit (ADR 000033) had an out-of-range value (`rate` or `burst` of
147    /// zero — a bucket that can never serve a token). Rejected fail-closed at build, like the
148    /// per-filter rate-limit validation, so a config typo cannot reach the limiter arithmetic.
149    #[error("route (prefix {path_prefix:?}) has invalid rate_limit: {reason}")]
150    InvalidRouteRateLimit { path_prefix: String, reason: String },
151
152    /// A TLS cert/key file could not be read, parsed, or built into a usable certificate
153    /// (ADR 000014). Fail-closed: a bad cert aborts the build, so reload never swaps in a TLS
154    /// config that cannot serve. `host` is the SNI the entry was for (`None` = default cert).
155    #[error("TLS cert for {host:?} ({path:?}): {reason}")]
156    TlsCert {
157        host: Option<String>,
158        path: String,
159        reason: String,
160    },
161
162    /// The `[resumption]` shared-STEK config (ADR 000062) is invalid or its key file failed the
163    /// fail-closed startup discipline: out-of-range `max_age_hours`, no `[[tls]]` cert to bind
164    /// tickets to, a key file that is unreadable / not exactly 64 raw bytes / readable by group
165    /// or other, or a cert whose SPKI the provider cannot expose (nothing to bind to). All abort
166    /// the build like a bad cert — a proxy never comes up sharing ticket keys it cannot protect.
167    #[error("[resumption] stek_file {path:?}: {reason}")]
168    Stek { path: String, reason: String },
169
170    /// An `[upstream.tls]` CA bundle could not be read or parsed, or yielded no usable root
171    /// (ADR 000042). Fail-closed at build, like `TlsCert`: a bad CA path aborts the build /
172    /// reload before the upstream registry mutates, so the forward leg never silently falls
173    /// back to unverified (or plaintext) forwarding.
174    #[error("upstream {upstream:?} TLS CA ({path:?}): {reason}")]
175    UpstreamTlsCa {
176        upstream: String,
177        path: String,
178        reason: String,
179    },
180
181    /// An `[upstream.tls] sni` verification-name override did not parse as a valid DNS name or IP
182    /// address (ADR 000050). Fail-closed at build, like `UpstreamTlsCa`: a bad `sni` aborts the
183    /// build / reload before the upstream registry mutates, rather than letting every handshake
184    /// to this upstream fail at request time.
185    #[error("upstream {upstream:?} TLS sni {sni:?}: {reason}")]
186    UpstreamTlsSni {
187        upstream: String,
188        sni: String,
189        reason: String,
190    },
191
192    /// An `[upstream.tls]` client identity (upstream mTLS, ADR 000078) could not be built:
193    /// `client_cert_path` / `client_key_path` set without the other, an unreadable / unparsable
194    /// PEM, a key file readable by group or other (the ADR 000062 (d) key-file discipline), or
195    /// a chain/key pair rustls rejects. Fail-closed at build, like `UpstreamTlsCa`: the forward
196    /// leg never silently connects WITHOUT the identity the backend requires.
197    #[error("upstream {upstream:?} client certificate ({path:?}): {reason}")]
198    UpstreamClientCert {
199        upstream: String,
200        path: String,
201        reason: String,
202    },
203
204    /// The `[listen.client_auth]` downstream-mTLS config (ADR 000078) is invalid: no `[[tls]]`
205    /// cert to terminate with, or a `ca_path` that is unreadable / unparsable / yields no
206    /// usable trust anchor. Fail-closed at build: the listener never comes up accepting
207    /// clients it was told to authenticate but cannot.
208    #[error("[listen.client_auth] ca_path {path:?}: {reason}")]
209    ClientAuthCa { path: String, reason: String },
210}