Skip to main content

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}