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}