use std::collections::{BTreeSet, HashMap};
use std::sync::Arc;
use uuid::Uuid;
use crate::db::Database;
use crate::nonce::now_secs;
use crate::order::{Order, UNPARSABLE_NOT_AFTER};
use acme_proxy_core::config::Config;
const DEFAULT_LEAD_DAYS: u64 = 30;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SupersededBy {
pub order_id: String,
pub cert_serial: String,
pub not_after: i64,
pub via: String,
}
#[derive(Debug)]
pub struct ExpiringEntry {
pub order: Order,
pub days_remaining: i64,
pub superseded_by: Option<SupersededBy>,
}
pub struct ExpiringQuery {
pub profile: Option<String>,
pub before: i64,
pub include_superseded: bool,
pub limit: i64,
pub offset: i64,
}
#[must_use]
pub fn expiring_horizon(days: u64) -> i64 {
horizon_from(now_secs(), days.saturating_mul(24 * 60 * 60))
}
#[must_use]
pub fn horizon_from(now: i64, lead_seconds: u64) -> i64 {
now.saturating_add(i64::try_from(lead_seconds).unwrap_or(i64::MAX))
}
#[must_use]
pub fn days_remaining(not_after: i64, now: i64) -> i64 {
not_after.saturating_sub(now).max(0) / (24 * 60 * 60)
}
#[must_use]
pub fn default_lead_days(config: &Config) -> u64 {
match config.notify.expiry.lead_days {
0 => DEFAULT_LEAD_DAYS,
days => days,
}
}
pub async fn superseded_by(
order: &Order,
candidates: &[Order],
database: &Database,
) -> Result<Option<SupersededBy>, sqlx::Error> {
if let Some(chain) = order.certificate.as_deref()
&& let Some(cert_id) = ari_cert_id(chain)
&& let Some(successor) = Order::find_by_replaces(&order.profile, &cert_id, database).await?
&& successor.certificate.is_some()
&& successor.revoked_at.is_none()
{
return Ok(Some(SupersededBy {
order_id: successor.id.to_string(),
cert_serial: successor.cert_serial.unwrap_or_default(),
not_after: successor.cert_not_after.unwrap_or_default(),
via: "replaces".to_string(),
}));
}
let names: BTreeSet<&str> = order
.identifiers
.iter()
.map(|identifier| identifier.value.as_str())
.collect();
let expires = order.cert_not_after.unwrap_or_default();
for candidate in candidates {
if candidate.id == order.id
|| candidate.certificate.is_none()
|| candidate.revoked_at.is_some()
|| candidate.cert_not_after.unwrap_or(UNPARSABLE_NOT_AFTER) <= expires
{
continue;
}
let covered: BTreeSet<&str> = candidate
.identifiers
.iter()
.map(|identifier| identifier.value.as_str())
.collect();
if names.is_subset(&covered) {
return Ok(Some(SupersededBy {
order_id: candidate.id.to_string(),
cert_serial: candidate.cert_serial.clone().unwrap_or_default(),
not_after: candidate.cert_not_after.unwrap_or_default(),
via: "identifiers".to_string(),
}));
}
}
Ok(None)
}
pub async fn annotate_expiring(
orders: Vec<Order>,
database: &Database,
) -> Result<Vec<ExpiringEntry>, sqlx::Error> {
let now = now_secs();
let mut by_account: HashMap<Uuid, Vec<Order>> = HashMap::new();
let mut entries = Vec::with_capacity(orders.len());
for order in orders {
if let std::collections::hash_map::Entry::Vacant(slot) = by_account.entry(order.account_id)
{
slot.insert(Order::find_by_account(order.account_id, database).await?);
}
let candidates = by_account
.get(&order.account_id)
.map_or(&[][..], Vec::as_slice);
let superseded = superseded_by(&order, candidates, database).await?;
entries.push(ExpiringEntry {
days_remaining: days_remaining(order.cert_not_after.unwrap_or_default(), now),
superseded_by: superseded,
order,
});
}
Ok(entries)
}
pub async fn list_expiring(
query: &ExpiringQuery,
database: Arc<Database>,
) -> Result<(Vec<ExpiringEntry>, i64, i64), sqlx::Error> {
let (orders, total) = Order::find_expiring(
query.profile.as_deref(),
query.before,
query.limit,
query.offset,
&database,
)
.await?;
let entries = annotate_expiring(orders, &database).await?;
if query.include_superseded {
return Ok((entries, total, 0));
}
let annotated = i64::try_from(entries.len()).unwrap_or(i64::MAX);
let kept: Vec<ExpiringEntry> = entries
.into_iter()
.filter(|entry| entry.superseded_by.is_none())
.collect();
let hidden = annotated.saturating_sub(i64::try_from(kept.len()).unwrap_or(i64::MAX));
Ok((kept, total, hidden))
}
fn ari_cert_id(chain: &str) -> Option<String> {
acme_proxy_core::cert::leaf_der_from_chain(chain)
.ok()
.and_then(|der| acme_proxy_core::cert::ari_cert_id(&der).ok())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::{account_id, issued_order};
use acme_proxy_core::identifier::Identifier;
const DAY: i64 = 24 * 60 * 60;
async fn db() -> Arc<Database> {
Arc::new(Database::connect_for_test().await.unwrap())
}
async fn issued(
db: &Database,
account: uuid::Uuid,
names: &[&str],
not_after_days: i64,
) -> Order {
issued_order(db, "default", account, names, not_after_days).await
}
async fn annotation(order: &Order, db: &Database) -> Option<SupersededBy> {
let candidates = Order::find_by_account(order.account_id, db).await.unwrap();
superseded_by(order, &candidates, db).await.unwrap()
}
#[tokio::test]
async fn a_replaces_claim_marks_the_predecessor_superseded() {
let db = db().await;
let acct = account_id(&db).await;
let old = issued(&db, acct, &["a.example.com"], 3).await;
let cert_id = ari_cert_id(old.certificate.as_deref().unwrap()).unwrap();
let successor = issued(&db, acct, &["a.example.com"], 90).await;
crate::sql::query("UPDATE orders SET replaces = ? WHERE id = ?;")
.bind(&cert_id)
.bind(successor.id)
.execute(&*db)
.await
.unwrap();
let reloaded = Order::find_by_id(old.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
let superseded = annotation(&reloaded, &db).await.unwrap();
assert_eq!(superseded.order_id, successor.id.to_string());
assert_eq!(superseded.via, "replaces");
}
#[tokio::test]
async fn a_pending_replaces_claim_supersedes_nothing() {
let db = db().await;
let acct = account_id(&db).await;
let old = issued(&db, acct, &["a.example.com"], 3).await;
let cert_id = ari_cert_id(old.certificate.as_deref().unwrap()).unwrap();
let pending = Order::create(
"default",
acct,
vec![Identifier::dns("a.example.com")],
now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
crate::sql::query("UPDATE orders SET replaces = ? WHERE id = ?;")
.bind(&cert_id)
.bind(pending.id)
.execute(&*db)
.await
.unwrap();
let reloaded = Order::find_by_id(old.id.to_string().as_str(), &db)
.await
.unwrap()
.unwrap();
assert!(annotation(&reloaded, &db).await.is_none());
}
#[tokio::test]
async fn a_later_certificate_over_the_same_names_supersedes() {
let db = db().await;
let acct = account_id(&db).await;
let old = issued(&db, acct, &["a.example.com"], 3).await;
let new = issued(&db, acct, &["a.example.com", "b.example.com"], 90).await;
let superseded = annotation(&old, &db).await.unwrap();
assert_eq!(superseded.order_id, new.id.to_string());
assert_eq!(
superseded.via, "identifiers",
"a superset covers these names, so it is a renewal"
);
}
#[tokio::test]
async fn a_partial_a_revoked_and_another_accounts_certificate_supersede_nothing() {
let db = db().await;
let acct = account_id(&db).await;
let old = issued(&db, acct, &["a.example.com", "b.example.com"], 3).await;
issued(&db, acct, &["a.example.com"], 90).await;
assert!(
annotation(&old, &db).await.is_none(),
"a subset is not a renewal"
);
let mut revoked = issued(&db, acct, &["a.example.com", "b.example.com"], 90).await;
revoked.revoke(Some(1), &db).await.unwrap();
assert!(
annotation(&old, &db).await.is_none(),
"a revoked certificate covers nothing"
);
let (other, _created) = crate::account::Account::find_or_create(
"default",
b"other-key",
Vec::new(),
&acme_proxy_core::audit::ClientContext::default(),
&db,
)
.await
.unwrap();
issued(&db, other.id, &["a.example.com", "b.example.com"], 90).await;
assert!(
annotation(&old, &db).await.is_none(),
"another subscriber's certificate is not this one's renewal"
);
}
#[test]
fn the_horizon_and_the_day_count_agree_on_a_whole_day() {
let now = now_secs();
assert!((expiring_horizon(7) - now - 7 * DAY).abs() <= 1);
assert_eq!(expiring_horizon(u64::MAX), i64::MAX);
assert_eq!(days_remaining(1_000 + 3 * DAY + DAY / 2, 1_000), 3);
assert_eq!(days_remaining(1_000 + DAY - 1, 1_000), 0);
assert_eq!(days_remaining(1_000, 1_000 + 5 * DAY), 0);
assert_eq!(days_remaining(i64::MIN, i64::MAX), 0);
}
#[test]
fn the_default_window_falls_back_only_when_the_digest_is_off() {
let mut config = Config::default();
assert_eq!(config.notify.expiry.lead_days, 0, "off by default");
assert_eq!(default_lead_days(&config), DEFAULT_LEAD_DAYS);
config.notify.expiry.lead_days = 3;
assert_eq!(
default_lead_days(&config),
3,
"a deployment that chose a lead time gets it"
);
}
#[tokio::test]
async fn a_listing_reads_each_accounts_orders_once_and_still_annotates_each_row() {
let db = db().await;
let acct = account_id(&db).await;
let a = issued(&db, acct, &["a.example.com"], 3).await;
let b = issued(&db, acct, &["b.example.com"], 5).await;
let renewal = issued(&db, acct, &["a.example.com"], 90).await;
let (orders, _total) = Order::find_expiring(None, expiring_horizon(30), 50, 0, &db)
.await
.unwrap();
let entries = annotate_expiring(orders, &db).await.unwrap();
let annotated = |id: &str| -> Option<SupersededBy> {
entries
.iter()
.find(|entry| entry.order.id.to_string() == id)
.and_then(|entry| entry.superseded_by.clone())
};
assert_eq!(
annotated(a.id.to_string().as_str()).unwrap().order_id,
renewal.id.to_string()
);
assert!(
annotated(b.id.to_string().as_str()).is_none(),
"a shared candidate list must not leak one row's renewal onto another"
);
Order::set_cert_not_after(a.id, now_secs() + 3 * DAY + DAY / 2, &db)
.await
.unwrap();
let (orders, _total) = Order::find_expiring(None, expiring_horizon(30), 50, 0, &db)
.await
.unwrap();
let entries = annotate_expiring(orders, &db).await.unwrap();
let a_entry = entries.iter().find(|e| e.order.id == a.id).unwrap();
assert_eq!(a_entry.days_remaining, 3, "floored, not rounded");
}
#[tokio::test]
async fn hiding_superseded_rows_reports_the_count_rather_than_shrinking_the_total() {
let db = db().await;
let acct = account_id(&db).await;
let a = issued(&db, acct, &["a.example.com"], 3).await;
issued(&db, acct, &["b.example.com"], 5).await;
issued(&db, acct, &["a.example.com"], 90).await;
let query = |include: bool| ExpiringQuery {
profile: None,
before: expiring_horizon(30),
include_superseded: include,
limit: 50,
offset: 0,
};
let (shown, total, hidden) = list_expiring(&query(true), db.clone()).await.unwrap();
assert_eq!(shown.len(), 2, "both expiring rows, annotated");
assert_eq!(total, 2);
assert_eq!(hidden, 0);
let (kept, total, hidden) = list_expiring(&query(false), db.clone()).await.unwrap();
assert_eq!(kept.len(), 1);
assert!(kept.iter().all(|entry| entry.superseded_by.is_none()));
assert_ne!(kept[0].order.id, a.id, "the replaced row is the one hidden");
assert_eq!(hidden, 1);
assert_eq!(
total, 2,
"the total counts the window, not the answer -- documented on list_expiring"
);
}
#[tokio::test]
async fn the_listing_scopes_by_profile_and_answers_soonest_first() {
let db = db().await;
let acct = account_id(&db).await;
let here = issued_order(&db, "default", acct, &["a.example.com"], 5).await;
let sooner = issued_order(&db, "default", acct, &["b.example.com"], 2).await;
issued_order(&db, "other", acct, &["c.example.com"], 1).await;
let scoped = ExpiringQuery {
profile: Some("default".to_string()),
before: expiring_horizon(30),
include_superseded: true,
limit: 50,
offset: 0,
};
let (entries, total, _) = list_expiring(&scoped, db.clone()).await.unwrap();
let ids: Vec<String> = entries
.iter()
.map(|entry| entry.order.id.to_string())
.collect();
assert_eq!(ids, vec![sooner.id.to_string(), here.id.to_string()]);
assert_eq!(total, 2);
let unscoped = ExpiringQuery {
profile: None,
..scoped
};
let (entries, total, _) = list_expiring(&unscoped, db).await.unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(total, 3);
}
}