acme_proxy/sqlite/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//! `handlers/`, `sqlite/` 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 /// `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 only ever reached by a signer backend that defers
98 /// issuance (`relay`); `local_ca` answers inline and goes straight to
99 /// `valid`. There is deliberately no `revoked`: RFC 8555 defines none, and
100 /// revocation is recorded on its own columns
101 /// (see [`Order::revoke`](crate::sqlite::order::Order::revoke)).
102 OrderStatus("order status") {
103 Pending => "pending",
104 Ready => "ready",
105 Processing => "processing",
106 Valid => "valid",
107 Invalid => "invalid",
108 }
109
110 /// An authorization's state (RFC 8555 §7.1.6).
111 ///
112 /// The column's `CHECK` has always permitted `deactivated`, `expired` and
113 /// `revoked`; only `deactivated` is ever written today (§7.5.2), which is
114 /// why implementing it needed no migration. The other two are kept here so
115 /// the type can still load a row holding one.
116 AuthzStatus("authorization status") {
117 Pending => "pending",
118 Valid => "valid",
119 Invalid => "invalid",
120 Deactivated => "deactivated",
121 Expired => "expired",
122 Revoked => "revoked",
123 }
124
125 /// A challenge's state (RFC 8555 §8).
126 ///
127 /// `processing` is in the `CHECK` but never written: validation here is
128 /// inline and synchronous under `challenge.timeout_ms`, so a triggered
129 /// challenge is `valid` or `invalid` by the time the response is built.
130 ChallengeStatus("challenge status") {
131 Pending => "pending",
132 Processing => "processing",
133 Valid => "valid",
134 Invalid => "invalid",
135 }
136}
137
138/// Reads a status column, turning an unrecognised value into a decode error.
139///
140/// Refusing to load is the right failure: the alternative is holding the raw
141/// string and comparing against it, which is what this module exists to stop.
142/// The `CHECK` constraint means no build honouring the schema can write one.
143pub(crate) fn from_column<T>(value: &str) -> Result<T, sqlx::Error>
144where
145 T: FromStr<Err = UnknownStatus>,
146{
147 value
148 .parse()
149 .map_err(|error: UnknownStatus| sqlx::Error::Decode(Box::new(error)))
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 /// Every variant survives the trip its column makes it take. This is the
157 /// test that would catch a variant whose `as_str` no longer matches what
158 /// the frozen `CHECK` permits.
159 #[test]
160 fn every_status_round_trips_through_its_stored_string() {
161 for status in OrderStatus::ALL {
162 assert_eq!(status.as_str().parse::<OrderStatus>().unwrap(), *status);
163 }
164 for status in AuthzStatus::ALL {
165 assert_eq!(status.as_str().parse::<AuthzStatus>().unwrap(), *status);
166 }
167 for status in ChallengeStatus::ALL {
168 assert_eq!(status.as_str().parse::<ChallengeStatus>().unwrap(), *status);
169 }
170 }
171
172 /// The stored spellings are the compatibility surface, so they are asserted
173 /// literally rather than derived — a test that computed them from the enum
174 /// would agree with any rename.
175 #[test]
176 fn the_stored_spellings_are_the_ones_the_check_constraints_permit() {
177 assert_eq!(
178 OrderStatus::SPELLINGS,
179 &["pending", "ready", "processing", "valid", "invalid"]
180 );
181 assert_eq!(
182 AuthzStatus::SPELLINGS,
183 &[
184 "pending",
185 "valid",
186 "invalid",
187 "deactivated",
188 "expired",
189 "revoked"
190 ]
191 );
192 assert_eq!(
193 ChallengeStatus::SPELLINGS,
194 &["pending", "processing", "valid", "invalid"]
195 );
196 }
197
198 #[test]
199 fn an_unknown_status_names_itself_and_the_alternatives() {
200 let error = "readyy".parse::<OrderStatus>().unwrap_err();
201 let rendered = error.to_string();
202 assert!(
203 rendered.contains("unknown order status `readyy`"),
204 "{rendered}"
205 );
206 assert!(
207 rendered.contains("pending, ready, processing, valid, invalid"),
208 "{rendered}"
209 );
210 }
211
212 /// A status that belongs to a *different* machine is still unknown here —
213 /// the point of three types rather than one.
214 #[test]
215 fn a_status_from_another_state_machine_is_refused() {
216 assert!("deactivated".parse::<OrderStatus>().is_err());
217 assert!("ready".parse::<AuthzStatus>().is_err());
218 assert!("deactivated".parse::<ChallengeStatus>().is_err());
219 // ...but each machine's own values still parse.
220 assert!("deactivated".parse::<AuthzStatus>().is_ok());
221 }
222
223 #[test]
224 fn a_bad_column_value_is_a_decode_error_not_a_panic() {
225 let error = from_column::<OrderStatus>("nonsense").unwrap_err();
226 assert!(matches!(error, sqlx::Error::Decode(_)), "{error:?}");
227 }
228}