1use tracing::{debug, info};
9
10use crate::db::Database;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Revocation {
15 pub issuer: String,
17 pub serial: String,
19 pub revoked_at: i64,
21 pub reason: Option<u32>,
23 pub not_after: Option<i64>,
25}
26
27impl Revocation {
28 pub async fn insert_if_absent<'e>(
34 &self,
35 executor: impl Into<crate::sql::Exec<'e>>,
36 ) -> Result<bool, sqlx::Error> {
37 let result = crate::sql::query(
38 "INSERT INTO revocations (issuer, serial, revoked_at, reason, not_after) \
39 VALUES (?, ?, ?, ?, ?) ON CONFLICT (issuer, serial) DO NOTHING;",
40 )
41 .bind(&self.issuer)
42 .bind(&self.serial)
43 .bind(self.revoked_at)
44 .bind(self.reason.map(i64::from))
45 .bind(self.not_after)
46 .execute(executor)
47 .await?;
48 let inserted = result.rows_affected() == 1;
49 debug!(
50 event = "db_revocation_recorded",
51 outcome = "success",
52 issuer = %self.issuer,
53 cert_serial = %self.serial,
54 inserted,
55 );
56 Ok(inserted)
57 }
58
59 pub async fn list_for_issuer<'e>(
61 issuer: &str,
62 executor: impl Into<crate::sql::Exec<'e>>,
63 ) -> Result<Vec<Self>, sqlx::Error> {
64 let rows = crate::sql::query(
65 "SELECT issuer, serial, revoked_at, reason, not_after FROM revocations \
66 WHERE issuer = ? ORDER BY revoked_at, serial;",
67 )
68 .bind(issuer)
69 .fetch_all(executor)
70 .await?;
71 rows.iter()
72 .map(|row| {
73 let reason: Option<i64> = row.try_get("reason")?;
74 Ok(Self {
75 issuer: row.try_get("issuer")?,
76 serial: row.try_get("serial")?,
77 revoked_at: row.try_get("revoked_at")?,
78 reason: reason.and_then(|code| u32::try_from(code).ok()),
83 not_after: row.try_get("not_after")?,
84 })
85 })
86 .collect()
87 }
88
89 pub async fn count_expired(
92 issuer: &str,
93 cutoff: i64,
94 listed_before: i64,
95 database: &Database,
96 ) -> Result<u64, sqlx::Error> {
97 let count: i64 = crate::sql::query(
98 "SELECT COUNT(*) FROM revocations \
99 WHERE issuer = ? AND not_after IS NOT NULL AND not_after < ? \
100 AND revoked_at <= ?;",
101 )
102 .bind(issuer)
103 .bind(cutoff)
104 .bind(listed_before)
105 .fetch_one(database)
106 .await?
107 .try_get(0)?;
108 Ok(u64::try_from(count).unwrap_or_default())
109 }
110
111 pub async fn prune_expired<'e>(
134 issuer: &str,
135 cutoff: i64,
136 listed_before: i64,
137 executor: impl Into<crate::sql::Exec<'e>>,
138 ) -> Result<u64, sqlx::Error> {
139 let result = crate::sql::query(
140 "DELETE FROM revocations \
141 WHERE issuer = ? AND not_after IS NOT NULL AND not_after < ? \
142 AND revoked_at <= ?;",
143 )
144 .bind(issuer)
145 .bind(cutoff)
146 .bind(listed_before)
147 .execute(executor)
148 .await?;
149 info!(
150 event = "db_revocation_pruned",
151 outcome = "success",
152 issuer = %issuer,
153 rows_removed = result.rows_affected(),
154 cutoff,
155 );
156 Ok(result.rows_affected())
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 fn revocation(serial: &str, revoked_at: i64, not_after: Option<i64>) -> Revocation {
165 Revocation {
166 issuer: "ca".to_string(),
167 serial: serial.to_string(),
168 revoked_at,
169 reason: Some(1),
170 not_after,
171 }
172 }
173
174 #[tokio::test]
175 async fn the_first_revocation_of_a_serial_is_the_one_kept() {
176 let database = Database::connect_for_test().await.unwrap();
177
178 assert!(
179 revocation("01", 100, None)
180 .insert_if_absent(&database)
181 .await
182 .unwrap()
183 );
184 let mut repeat = revocation("01", 200, Some(9));
185 repeat.reason = Some(4);
186 assert!(!repeat.insert_if_absent(&database).await.unwrap());
187
188 let listed = Revocation::list_for_issuer("ca", &database).await.unwrap();
189 assert_eq!(listed, vec![revocation("01", 100, None)]);
190 }
191
192 #[tokio::test]
194 async fn issuers_do_not_see_each_others_rows() {
195 let database = Database::connect_for_test().await.unwrap();
196 revocation("01", 100, None)
197 .insert_if_absent(&database)
198 .await
199 .unwrap();
200 let mut other = revocation("01", 100, None);
201 other.issuer = "other".to_string();
202 assert!(other.insert_if_absent(&database).await.unwrap());
203
204 assert_eq!(
205 Revocation::list_for_issuer("ca", &database)
206 .await
207 .unwrap()
208 .len(),
209 1
210 );
211 assert!(
212 Revocation::list_for_issuer("nobody", &database)
213 .await
214 .unwrap()
215 .is_empty()
216 );
217 }
218
219 #[tokio::test]
223 async fn an_entry_no_crl_has_outlived_is_kept() {
224 let database = Database::connect_for_test().await.unwrap();
225 revocation("expired", 10, Some(50))
227 .insert_if_absent(&database)
228 .await
229 .unwrap();
230
231 assert_eq!(
232 Revocation::count_expired("ca", 50, 40, &database)
233 .await
234 .unwrap(),
235 0
236 );
237 assert_eq!(
238 Revocation::prune_expired("ca", 50, 40, &database)
239 .await
240 .unwrap(),
241 0
242 );
243
244 assert_eq!(
246 Revocation::prune_expired("ca", 60, 60, &database)
247 .await
248 .unwrap(),
249 1
250 );
251 }
252
253 #[tokio::test]
254 async fn the_prune_takes_only_known_expiries_before_the_cutoff() {
255 let database = Database::connect_for_test().await.unwrap();
256 for row in [
257 revocation("expired", 1, Some(50)),
258 revocation("boundary", 2, Some(100)),
259 revocation("current", 3, Some(500)),
260 revocation("unknown", 4, None),
261 ] {
262 row.insert_if_absent(&database).await.unwrap();
263 }
264 let mut elsewhere = revocation("elsewhere", 5, Some(50));
265 elsewhere.issuer = "other".to_string();
266 elsewhere.insert_if_absent(&database).await.unwrap();
267
268 assert_eq!(
269 Revocation::count_expired("ca", 100, 1_000, &database)
270 .await
271 .unwrap(),
272 1
273 );
274 assert_eq!(
275 Revocation::prune_expired("ca", 100, 1_000, &database)
276 .await
277 .unwrap(),
278 1
279 );
280
281 let left: Vec<String> = Revocation::list_for_issuer("ca", &database)
282 .await
283 .unwrap()
284 .into_iter()
285 .map(|row| row.serial)
286 .collect();
287 assert_eq!(left, ["boundary", "current", "unknown"]);
288 assert_eq!(
289 Revocation::list_for_issuer("other", &database)
290 .await
291 .unwrap()
292 .len(),
293 1,
294 "a prune is scoped to its issuer"
295 );
296 }
297
298 #[tokio::test]
301 async fn an_out_of_range_reason_reads_as_none() {
302 let database = Database::connect_for_test().await.unwrap();
303 crate::sql::query(
304 "INSERT INTO revocations (issuer, serial, revoked_at, reason) VALUES ('ca', '01', 1, -3);",
305 )
306 .execute(&database)
307 .await
308 .unwrap();
309 let listed = Revocation::list_for_issuer("ca", &database).await.unwrap();
310 assert_eq!(listed[0].reason, None);
311 }
312}