acme_proxy_store/status.rs
1//! The three ACME state machines, as types rather than strings.
2//!
3//! `orders.status`, `authorizations.status` and `challenges.status` are
4//! `TEXT` columns with a `CHECK` naming the values each may hold, and the code
5//! around them used to compare against string literals — `order.status ==
6//! "valid"`, `authz.status != "pending"` — at thirty-odd sites spread over
7//! the handlers, the storage layer and the relay flow. A typo in one of those compiles
8//! and silently changes policy: `!= "readyy"` is always true, and the order it
9//! guards becomes finalizable for names nobody proved control of.
10//!
11//! These enums are a Rust-side change only. Each variant's [`as_str`] is the
12//! byte-identical string the column already holds, so the frozen migrations and
13//! their `CHECK` constraints are untouched, and RFC 8555 still sees exactly the
14//! spellings it defines.
15//!
16//! [`as_str`]: OrderStatus::as_str
17
18use std::fmt;
19use std::str::FromStr;
20
21/// A status column held a value outside its `CHECK` — or, far more likely, an
22/// operator typed one.
23///
24/// Carries the permitted values because both places this surfaces want to print
25/// them: `acme-proxy order list --status typo` refuses **by name** rather than
26/// asking SQL, which would answer "no rows" and look exactly like "nothing is
27/// in that state" (the rule `audit list --event` already follows), and a row
28/// that somehow holds an unknown value fails to load rather than being silently
29/// compared against and mis-handled.
30#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
31#[error("unknown {label} `{value}` (expected one of: {})", allowed.join(", "))]
32pub struct UnknownStatus {
33 pub label: &'static str,
34 pub value: String,
35 pub allowed: &'static [&'static str],
36}
37
38macro_rules! statuses {
39 ($(
40 $(#[$doc:meta])*
41 $name:ident ($label:literal) {
42 $( $(#[$vdoc:meta])* $variant:ident => $wire:literal ),+ $(,)?
43 }
44 )*) => { $(
45 $(#[$doc])*
46 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47 pub enum $name {
48 $( $(#[$vdoc])* $variant, )+
49 }
50
51 impl $name {
52 /// Every value, in the order RFC 8555 introduces them. Used to word
53 /// [`UnknownStatus`] and to drive the round-trip test.
54 pub const ALL: &'static [Self] = &[ $( Self::$variant, )+ ];
55
56 /// The exact string the column holds and the wire carries.
57 ///
58 /// This is the compatibility surface: the `CHECK` constraints in
59 /// `crates/store/migrations/20260727120000_indexes_and_constraints.sql` are
60 /// written against these literals and the migrations are frozen, so
61 /// changing one is a schema change, not a rename.
62 #[must_use]
63 pub fn as_str(self) -> &'static str {
64 match self { $( Self::$variant => $wire, )+ }
65 }
66
67 /// The permitted spellings, for an error message.
68 const SPELLINGS: &'static [&'static str] = &[ $( $wire, )+ ];
69 }
70
71 impl FromStr for $name {
72 type Err = UnknownStatus;
73
74 fn from_str(value: &str) -> Result<Self, Self::Err> {
75 match value {
76 $( $wire => Ok(Self::$variant), )+
77 other => Err(UnknownStatus {
78 label: $label,
79 value: other.to_string(),
80 allowed: Self::SPELLINGS,
81 }),
82 }
83 }
84 }
85
86 impl fmt::Display for $name {
87 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
88 formatter.write_str(self.as_str())
89 }
90 }
91 )* };
92}
93
94statuses! {
95 /// An order's state (RFC 8555 §7.1.6).
96 ///
97 /// `processing` is every order's state between `finalize` and its
98 /// certificate: the order is claimed and its issuance queued, the worker
99 /// signs, and a delegating backend (`relay`) keeps it `processing` until its
100 /// upstream answers. There is deliberately no `revoked`: RFC 8555 defines none, and
101 /// revocation is recorded on its own columns
102 /// (see [`Order::revoke`](crate::order::Order::revoke)).
103 OrderStatus("order status") {
104 Pending => "pending",
105 Ready => "ready",
106 Processing => "processing",
107 Valid => "valid",
108 Invalid => "invalid",
109 }
110
111 /// An authorization's state (RFC 8555 §7.1.6).
112 ///
113 /// The column's `CHECK` has always permitted `deactivated`, `expired` and
114 /// `revoked`; only `deactivated` is ever written today (§7.5.2), which is
115 /// why implementing it needed no migration. The other two are kept here so
116 /// the type can still load a row holding one.
117 AuthzStatus("authorization status") {
118 Pending => "pending",
119 Valid => "valid",
120 Invalid => "invalid",
121 Deactivated => "deactivated",
122 Expired => "expired",
123 Revoked => "revoked",
124 }
125
126 /// A challenge's state (RFC 8555 §8).
127 ///
128 /// `processing` is what a triggered challenge answers with: the trigger
129 /// claims the row and queues the outbound check, and the job runner reaches
130 /// the verdict. §7.1.6 defines it for exactly this — "they transition to
131 /// the `processing` state when the client responds to the challenge" — and
132 /// §8.2 pairs it with the `Retry-After` the handler adds.
133 ChallengeStatus("challenge status") {
134 Pending => "pending",
135 Processing => "processing",
136 Valid => "valid",
137 Invalid => "invalid",
138 }
139
140 /// A background job's lifecycle (`crates/store/migrations/20260815120000_add_jobs.sql`).
141 ///
142 /// The runner drives `ready`/`running`/`done`/`failed`; `cancelled` has
143 /// been in the `CHECK` since the table was added and is written only by the
144 /// operator surface (`acme-proxy jobs cancel`, `POST /api/jobs/{id}/cancel`).
145 /// This enum is a front-end concern only — `Job::status` stays a `String`
146 /// so an older binary still renders a row a newer one wrote.
147 JobStatus("job status") {
148 Ready => "ready",
149 Running => "running",
150 Done => "done",
151 Failed => "failed",
152 Cancelled => "cancelled",
153 }
154
155 /// A relay `upstream_orders` row's lifecycle
156 /// (`crates/store/migrations/20260730120000_add_upstream_orders.sql`).
157 ///
158 /// Same Rust-side-only treatment as [`JobStatus`]: `UpstreamOrder::status`
159 /// stays a `String`, and this exists so `upstream order list --status` and
160 /// `GET /api/upstream-orders?status=` refuse an unknown value by name.
161 UpstreamOrderStatus("upstream order status") {
162 Processing => "processing",
163 Valid => "valid",
164 Invalid => "invalid",
165 }
166}
167
168/// Reads a status column, turning an unrecognised value into a decode error.
169///
170/// Refusing to load is the right failure: the alternative is holding the raw
171/// string and comparing against it, which is what this module exists to stop.
172/// The `CHECK` constraint means no build honouring the schema can write one.
173pub(crate) fn from_column<T>(value: &str) -> Result<T, sqlx::Error>
174where
175 T: FromStr<Err = UnknownStatus>,
176{
177 value
178 .parse()
179 .map_err(|error: UnknownStatus| sqlx::Error::Decode(Box::new(error)))
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 /// Every variant survives the trip its column makes it take. This is the
187 /// test that would catch a variant whose `as_str` no longer matches what
188 /// the frozen `CHECK` permits.
189 #[test]
190 fn every_status_round_trips_through_its_stored_string() {
191 for status in OrderStatus::ALL {
192 assert_eq!(status.as_str().parse::<OrderStatus>().unwrap(), *status);
193 }
194 for status in AuthzStatus::ALL {
195 assert_eq!(status.as_str().parse::<AuthzStatus>().unwrap(), *status);
196 }
197 for status in ChallengeStatus::ALL {
198 assert_eq!(status.as_str().parse::<ChallengeStatus>().unwrap(), *status);
199 }
200 for status in JobStatus::ALL {
201 assert_eq!(status.as_str().parse::<JobStatus>().unwrap(), *status);
202 }
203 for status in UpstreamOrderStatus::ALL {
204 assert_eq!(
205 status.as_str().parse::<UpstreamOrderStatus>().unwrap(),
206 *status
207 );
208 }
209 }
210
211 /// The stored spellings are the compatibility surface, so they are asserted
212 /// literally rather than derived — a test that computed them from the enum
213 /// would agree with any rename.
214 #[test]
215 fn the_stored_spellings_are_the_ones_the_check_constraints_permit() {
216 assert_eq!(
217 OrderStatus::SPELLINGS,
218 &["pending", "ready", "processing", "valid", "invalid"]
219 );
220 assert_eq!(
221 AuthzStatus::SPELLINGS,
222 &[
223 "pending",
224 "valid",
225 "invalid",
226 "deactivated",
227 "expired",
228 "revoked"
229 ]
230 );
231 assert_eq!(
232 ChallengeStatus::SPELLINGS,
233 &["pending", "processing", "valid", "invalid"]
234 );
235 assert_eq!(
236 JobStatus::SPELLINGS,
237 &["ready", "running", "done", "failed", "cancelled"]
238 );
239 assert_eq!(
240 UpstreamOrderStatus::SPELLINGS,
241 &["processing", "valid", "invalid"]
242 );
243 }
244
245 #[test]
246 fn an_unknown_status_names_itself_and_the_alternatives() {
247 let error = "readyy".parse::<OrderStatus>().unwrap_err();
248 let rendered = error.to_string();
249 assert!(
250 rendered.contains("unknown order status `readyy`"),
251 "{rendered}"
252 );
253 assert!(
254 rendered.contains("pending, ready, processing, valid, invalid"),
255 "{rendered}"
256 );
257 }
258
259 /// A status that belongs to a *different* machine is still unknown here —
260 /// the point of three types rather than one.
261 #[test]
262 fn a_status_from_another_state_machine_is_refused() {
263 assert!("deactivated".parse::<OrderStatus>().is_err());
264 assert!("ready".parse::<AuthzStatus>().is_err());
265 assert!("deactivated".parse::<ChallengeStatus>().is_err());
266 // ...but each machine's own values still parse.
267 assert!("deactivated".parse::<AuthzStatus>().is_ok());
268
269 // `JobStatus` and `UpstreamOrderStatus` share spellings with the ACME
270 // machines (`ready`, `valid`, `invalid`, `processing`) but are still
271 // their own types.
272 assert!("pending".parse::<JobStatus>().is_err());
273 assert!("processing".parse::<JobStatus>().is_err());
274 assert!("running".parse::<UpstreamOrderStatus>().is_err());
275 assert!("ready".parse::<UpstreamOrderStatus>().is_err());
276 assert!("cancelled".parse::<JobStatus>().is_ok());
277 assert!("processing".parse::<UpstreamOrderStatus>().is_ok());
278 }
279
280 #[test]
281 fn a_bad_column_value_is_a_decode_error_not_a_panic() {
282 let error = from_column::<OrderStatus>("nonsense").unwrap_err();
283 assert!(matches!(error, sqlx::Error::Decode(_)), "{error:?}");
284 }
285}