1use std::collections::{BTreeSet, HashMap};
13use std::sync::Arc;
14
15use uuid::Uuid;
16
17use crate::db::Database;
18use crate::nonce::now_secs;
19use crate::order::{Order, UNPARSABLE_NOT_AFTER};
20use acme_proxy_core::config::Config;
21
22const DEFAULT_LEAD_DAYS: u64 = 30;
28
29#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
38pub struct SupersededBy {
39 pub order_id: String,
40 pub cert_serial: String,
41 pub not_after: i64,
42 pub via: String,
44}
45
46#[derive(Debug)]
49pub struct ExpiringEntry {
50 pub order: Order,
51 pub days_remaining: i64,
52 pub superseded_by: Option<SupersededBy>,
53}
54
55pub struct ExpiringQuery {
57 pub profile: Option<String>,
59 pub before: i64,
63 pub include_superseded: bool,
67 pub limit: i64,
68 pub offset: i64,
69}
70
71#[must_use]
77pub fn expiring_horizon(days: u64) -> i64 {
78 horizon_from(now_secs(), days.saturating_mul(24 * 60 * 60))
79}
80
81#[must_use]
85pub fn horizon_from(now: i64, lead_seconds: u64) -> i64 {
86 now.saturating_add(i64::try_from(lead_seconds).unwrap_or(i64::MAX))
87}
88
89#[must_use]
96pub fn days_remaining(not_after: i64, now: i64) -> i64 {
97 not_after.saturating_sub(now).max(0) / (24 * 60 * 60)
98}
99
100#[must_use]
103pub fn default_lead_days(config: &Config) -> u64 {
104 match config.notify.expiry.lead_days {
105 0 => DEFAULT_LEAD_DAYS,
106 days => days,
107 }
108}
109
110pub async fn superseded_by(
123 order: &Order,
124 candidates: &[Order],
125 database: &Database,
126) -> Result<Option<SupersededBy>, sqlx::Error> {
127 if let Some(chain) = order.certificate.as_deref()
137 && let Some(cert_id) = ari_cert_id(chain)
138 && let Some(successor) = Order::find_by_replaces(&order.profile, &cert_id, database).await?
139 && successor.certificate.is_some()
140 && successor.revoked_at.is_none()
141 {
142 return Ok(Some(SupersededBy {
143 order_id: successor.id.to_string(),
144 cert_serial: successor.cert_serial.unwrap_or_default(),
145 not_after: successor.cert_not_after.unwrap_or_default(),
146 via: "replaces".to_string(),
147 }));
148 }
149
150 let names: BTreeSet<&str> = order
156 .identifiers
157 .iter()
158 .map(|identifier| identifier.value.as_str())
159 .collect();
160 let expires = order.cert_not_after.unwrap_or_default();
161 for candidate in candidates {
162 if candidate.id == order.id
163 || candidate.certificate.is_none()
164 || candidate.revoked_at.is_some()
165 || candidate.cert_not_after.unwrap_or(UNPARSABLE_NOT_AFTER) <= expires
166 {
167 continue;
168 }
169 let covered: BTreeSet<&str> = candidate
170 .identifiers
171 .iter()
172 .map(|identifier| identifier.value.as_str())
173 .collect();
174 if names.is_subset(&covered) {
175 return Ok(Some(SupersededBy {
176 order_id: candidate.id.to_string(),
177 cert_serial: candidate.cert_serial.clone().unwrap_or_default(),
178 not_after: candidate.cert_not_after.unwrap_or_default(),
179 via: "identifiers".to_string(),
180 }));
181 }
182 }
183
184 Ok(None)
185}
186
187pub async fn annotate_expiring(
199 orders: Vec<Order>,
200 database: &Database,
201) -> Result<Vec<ExpiringEntry>, sqlx::Error> {
202 let now = now_secs();
203 let mut by_account: HashMap<Uuid, Vec<Order>> = HashMap::new();
204 let mut entries = Vec::with_capacity(orders.len());
205
206 for order in orders {
207 if let std::collections::hash_map::Entry::Vacant(slot) = by_account.entry(order.account_id)
208 {
209 slot.insert(Order::find_by_account(order.account_id, database).await?);
210 }
211 let candidates = by_account
214 .get(&order.account_id)
215 .map_or(&[][..], Vec::as_slice);
216 let superseded = superseded_by(&order, candidates, database).await?;
217 entries.push(ExpiringEntry {
218 days_remaining: days_remaining(order.cert_not_after.unwrap_or_default(), now),
219 superseded_by: superseded,
220 order,
221 });
222 }
223
224 Ok(entries)
225}
226
227pub async fn list_expiring(
239 query: &ExpiringQuery,
240 database: Arc<Database>,
241) -> Result<(Vec<ExpiringEntry>, i64, i64), sqlx::Error> {
242 let (orders, total) = Order::find_expiring(
243 query.profile.as_deref(),
244 query.before,
245 query.limit,
246 query.offset,
247 &database,
248 )
249 .await?;
250 let entries = annotate_expiring(orders, &database).await?;
251
252 if query.include_superseded {
253 return Ok((entries, total, 0));
254 }
255 let annotated = i64::try_from(entries.len()).unwrap_or(i64::MAX);
257 let kept: Vec<ExpiringEntry> = entries
258 .into_iter()
259 .filter(|entry| entry.superseded_by.is_none())
260 .collect();
261 let hidden = annotated.saturating_sub(i64::try_from(kept.len()).unwrap_or(i64::MAX));
262 Ok((kept, total, hidden))
263}
264
265fn ari_cert_id(chain: &str) -> Option<String> {
267 acme_proxy_core::cert::leaf_der_from_chain(chain)
268 .ok()
269 .and_then(|der| acme_proxy_core::cert::ari_cert_id(&der).ok())
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use crate::testutil::{account_id, issued_order};
276 use acme_proxy_core::identifier::Identifier;
277
278 const DAY: i64 = 24 * 60 * 60;
279
280 async fn db() -> Arc<Database> {
281 Arc::new(Database::connect_for_test().await.unwrap())
282 }
283
284 async fn issued(
286 db: &Database,
287 account: uuid::Uuid,
288 names: &[&str],
289 not_after_days: i64,
290 ) -> Order {
291 issued_order(db, "default", account, names, not_after_days).await
292 }
293
294 async fn annotation(order: &Order, db: &Database) -> Option<SupersededBy> {
297 let candidates = Order::find_by_account(order.account_id, db).await.unwrap();
298 superseded_by(order, &candidates, db).await.unwrap()
299 }
300
301 #[tokio::test]
303 async fn a_replaces_claim_marks_the_predecessor_superseded() {
304 let db = db().await;
305 let acct = account_id(&db).await;
306 let old = issued(&db, acct, &["a.example.com"], 3).await;
307
308 let cert_id = ari_cert_id(old.certificate.as_deref().unwrap()).unwrap();
309 let successor = issued(&db, acct, &["a.example.com"], 90).await;
310 crate::sql::query("UPDATE orders SET replaces = ? WHERE id = ?;")
311 .bind(&cert_id)
312 .bind(successor.id)
313 .execute(&*db)
314 .await
315 .unwrap();
316
317 let reloaded = Order::find_by_id(old.id.to_string().as_str(), &db)
318 .await
319 .unwrap()
320 .unwrap();
321 let superseded = annotation(&reloaded, &db).await.unwrap();
322 assert_eq!(superseded.order_id, successor.id.to_string());
323 assert_eq!(superseded.via, "replaces");
324 }
325
326 #[tokio::test]
331 async fn a_pending_replaces_claim_supersedes_nothing() {
332 let db = db().await;
333 let acct = account_id(&db).await;
334 let old = issued(&db, acct, &["a.example.com"], 3).await;
335 let cert_id = ari_cert_id(old.certificate.as_deref().unwrap()).unwrap();
336
337 let pending = Order::create(
339 "default",
340 acct,
341 vec![Identifier::dns("a.example.com")],
342 now_secs() + 3600,
343 None,
344 None,
345 &db,
346 )
347 .await
348 .unwrap();
349 crate::sql::query("UPDATE orders SET replaces = ? WHERE id = ?;")
350 .bind(&cert_id)
351 .bind(pending.id)
352 .execute(&*db)
353 .await
354 .unwrap();
355
356 let reloaded = Order::find_by_id(old.id.to_string().as_str(), &db)
357 .await
358 .unwrap()
359 .unwrap();
360 assert!(annotation(&reloaded, &db).await.is_none());
361 }
362
363 #[tokio::test]
365 async fn a_later_certificate_over_the_same_names_supersedes() {
366 let db = db().await;
367 let acct = account_id(&db).await;
368 let old = issued(&db, acct, &["a.example.com"], 3).await;
369 let new = issued(&db, acct, &["a.example.com", "b.example.com"], 90).await;
370
371 let superseded = annotation(&old, &db).await.unwrap();
372 assert_eq!(superseded.order_id, new.id.to_string());
373 assert_eq!(
374 superseded.via, "identifiers",
375 "a superset covers these names, so it is a renewal"
376 );
377 }
378
379 #[tokio::test]
383 async fn a_partial_a_revoked_and_another_accounts_certificate_supersede_nothing() {
384 let db = db().await;
385 let acct = account_id(&db).await;
386 let old = issued(&db, acct, &["a.example.com", "b.example.com"], 3).await;
387
388 issued(&db, acct, &["a.example.com"], 90).await;
390 assert!(
391 annotation(&old, &db).await.is_none(),
392 "a subset is not a renewal"
393 );
394
395 let mut revoked = issued(&db, acct, &["a.example.com", "b.example.com"], 90).await;
397 revoked.revoke(Some(1), &db).await.unwrap();
398 assert!(
399 annotation(&old, &db).await.is_none(),
400 "a revoked certificate covers nothing"
401 );
402
403 let (other, _created) = crate::account::Account::find_or_create(
405 "default",
406 b"other-key",
407 Vec::new(),
408 &acme_proxy_core::audit::ClientContext::default(),
409 &db,
410 )
411 .await
412 .unwrap();
413 issued(&db, other.id, &["a.example.com", "b.example.com"], 90).await;
414 assert!(
415 annotation(&old, &db).await.is_none(),
416 "another subscriber's certificate is not this one's renewal"
417 );
418 }
419
420 #[test]
424 fn the_horizon_and_the_day_count_agree_on_a_whole_day() {
425 let now = now_secs();
426 assert!((expiring_horizon(7) - now - 7 * DAY).abs() <= 1);
427 assert_eq!(expiring_horizon(u64::MAX), i64::MAX);
430
431 assert_eq!(days_remaining(1_000 + 3 * DAY + DAY / 2, 1_000), 3);
435 assert_eq!(days_remaining(1_000 + DAY - 1, 1_000), 0);
436 assert_eq!(days_remaining(1_000, 1_000 + 5 * DAY), 0);
439 assert_eq!(days_remaining(i64::MIN, i64::MAX), 0);
440 }
441
442 #[test]
445 fn the_default_window_falls_back_only_when_the_digest_is_off() {
446 let mut config = Config::default();
447 assert_eq!(config.notify.expiry.lead_days, 0, "off by default");
448 assert_eq!(default_lead_days(&config), DEFAULT_LEAD_DAYS);
449
450 config.notify.expiry.lead_days = 3;
451 assert_eq!(
452 default_lead_days(&config),
453 3,
454 "a deployment that chose a lead time gets it"
455 );
456 }
457
458 #[tokio::test]
467 async fn a_listing_reads_each_accounts_orders_once_and_still_annotates_each_row() {
468 let db = db().await;
469 let acct = account_id(&db).await;
470
471 let a = issued(&db, acct, &["a.example.com"], 3).await;
472 let b = issued(&db, acct, &["b.example.com"], 5).await;
473 let renewal = issued(&db, acct, &["a.example.com"], 90).await;
476
477 let (orders, _total) = Order::find_expiring(None, expiring_horizon(30), 50, 0, &db)
478 .await
479 .unwrap();
480 let entries = annotate_expiring(orders, &db).await.unwrap();
481
482 let annotated = |id: &str| -> Option<SupersededBy> {
483 entries
484 .iter()
485 .find(|entry| entry.order.id.to_string() == id)
486 .and_then(|entry| entry.superseded_by.clone())
487 };
488 assert_eq!(
489 annotated(a.id.to_string().as_str()).unwrap().order_id,
490 renewal.id.to_string()
491 );
492 assert!(
493 annotated(b.id.to_string().as_str()).is_none(),
494 "a shared candidate list must not leak one row's renewal onto another"
495 );
496 Order::set_cert_not_after(a.id, now_secs() + 3 * DAY + DAY / 2, &db)
500 .await
501 .unwrap();
502 let (orders, _total) = Order::find_expiring(None, expiring_horizon(30), 50, 0, &db)
503 .await
504 .unwrap();
505 let entries = annotate_expiring(orders, &db).await.unwrap();
506 let a_entry = entries.iter().find(|e| e.order.id == a.id).unwrap();
507 assert_eq!(a_entry.days_remaining, 3, "floored, not rounded");
508 }
509
510 #[tokio::test]
514 async fn hiding_superseded_rows_reports_the_count_rather_than_shrinking_the_total() {
515 let db = db().await;
516 let acct = account_id(&db).await;
517 let a = issued(&db, acct, &["a.example.com"], 3).await;
518 issued(&db, acct, &["b.example.com"], 5).await;
519 issued(&db, acct, &["a.example.com"], 90).await;
520
521 let query = |include: bool| ExpiringQuery {
522 profile: None,
523 before: expiring_horizon(30),
524 include_superseded: include,
525 limit: 50,
526 offset: 0,
527 };
528
529 let (shown, total, hidden) = list_expiring(&query(true), db.clone()).await.unwrap();
530 assert_eq!(shown.len(), 2, "both expiring rows, annotated");
531 assert_eq!(total, 2);
532 assert_eq!(hidden, 0);
533
534 let (kept, total, hidden) = list_expiring(&query(false), db.clone()).await.unwrap();
535 assert_eq!(kept.len(), 1);
536 assert!(kept.iter().all(|entry| entry.superseded_by.is_none()));
537 assert_ne!(kept[0].order.id, a.id, "the replaced row is the one hidden");
538 assert_eq!(hidden, 1);
539 assert_eq!(
540 total, 2,
541 "the total counts the window, not the answer -- documented on list_expiring"
542 );
543 }
544
545 #[tokio::test]
548 async fn the_listing_scopes_by_profile_and_answers_soonest_first() {
549 let db = db().await;
550 let acct = account_id(&db).await;
551 let here = issued_order(&db, "default", acct, &["a.example.com"], 5).await;
552 let sooner = issued_order(&db, "default", acct, &["b.example.com"], 2).await;
553 issued_order(&db, "other", acct, &["c.example.com"], 1).await;
554
555 let scoped = ExpiringQuery {
556 profile: Some("default".to_string()),
557 before: expiring_horizon(30),
558 include_superseded: true,
559 limit: 50,
560 offset: 0,
561 };
562 let (entries, total, _) = list_expiring(&scoped, db.clone()).await.unwrap();
563 let ids: Vec<String> = entries
564 .iter()
565 .map(|entry| entry.order.id.to_string())
566 .collect();
567 assert_eq!(ids, vec![sooner.id.to_string(), here.id.to_string()]);
568 assert_eq!(total, 2);
569
570 let unscoped = ExpiringQuery {
571 profile: None,
572 ..scoped
573 };
574 let (entries, total, _) = list_expiring(&unscoped, db).await.unwrap();
575 assert_eq!(entries.len(), 3);
576 assert_eq!(total, 3);
577 }
578}