Skip to main content

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}