Skip to main content

edgeguard/
acme_budget.rs

1//! ACME issuance budget — refuse to ask a CA for a certificate it is about to refuse.
2//!
3//! # The failure this prevents
4//!
5//! Certificate authorities rate-limit issuance, and the limits are low enough to hit by accident.
6//! Let's Encrypt allows **5 certificates per exact set of identifiers per 7 days**. An edge that
7//! re-orders on every start — because its certificate cache is not durable, or because a rollout
8//! replaces the instance — burns that in five restarts and then has **no certificate for a week**.
9//! That is not hypothetical: it is why the k3s edge deployment carries a dedicated persistent volume
10//! for the ACME cache, with a comment explaining exactly this.
11//!
12//! Without a budget the edge finds out by being refused, at which point the week has started.
13//!
14//! # Modelled as the CA models it
15//!
16//! Let's Encrypt publishes these as **token buckets with refill rates** — "50 per 7 days, refilling
17//! 1 every 202 minutes" — not as fixed windows. The difference matters in the dangerous direction: a
18//! fixed-window counter believes the whole allowance returns at a boundary, so it permits a burst
19//! the CA will refuse. GCRA is exactly the right shape, and this crate already has it in
20//! [`crate::limiter`], so the buckets reuse that arithmetic rather than growing a second
21//! rate-limiter.
22//!
23//! # What this ledger is, and what decides when it is not
24//!
25//! **It is per-edge.** Each edge keeps its own ledger, which fully covers the per-edge limit — the
26//! exact-identifier-set bucket, the one behind the outage above. It cannot, on its own, coordinate
27//! the per-registered-domain bucket across many edges: fifty edges under one registered domain
28//! would each believe they had the full 50-per-week allowance and between them spend it.
29//!
30//! That gap is closed **above** this module rather than inside it. In managed mode the edge asks the
31//! control plane for an issuance lease first (`crate::cp::CpClient::acme_lease`), and the control
32//! plane holds the shared buckets for the whole fleet — see `acme::check_budget` for the decision
33//! and the fallback. This ledger is then the authority in exactly two cases: an unmanaged edge, and
34//! a managed edge whose control plane cannot answer. Both are real, so it is not vestigial; but the
35//! per-registered-domain bucket **here** is still a local guard rather than a fleet total, and the
36//! metric help text says so rather than implying a number it does not have.
37//!
38//! # Never trades a real certificate for a self-signed one
39//!
40//! A refusal here is a *deferral*, never a downgrade. `main.rs` documents the invariant this
41//! respects: ACME runs before the self-signed floor precisely so a failed order cannot silently put
42//! an untrusted certificate on a public name. An exhausted budget keeps whatever certificate is
43//! already on disk and says so loudly; it does not manufacture one.
44
45use std::collections::HashMap;
46use std::path::{Path, PathBuf};
47use std::time::{Duration, SystemTime, UNIX_EPOCH};
48
49use anyhow::{Context, Result};
50use serde::{Deserialize, Serialize};
51use tracing::{info, warn};
52
53use crate::limiter::{gcra_admit, Gcra};
54
55/// Which CA limit refused an order. Also the metric label, so it is a fixed set.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Bucket {
58    /// New orders per ACME account.
59    Orders,
60    /// New certificates per registered domain.
61    RegisteredDomain,
62    /// New certificates per exact set of identifiers. The binding limit for a restart loop.
63    IdentifierSet,
64}
65
66impl Bucket {
67    pub fn label(&self) -> &'static str {
68        match self {
69            Bucket::Orders => "orders",
70            Bucket::RegisteredDomain => "registered_domain",
71            Bucket::IdentifierSet => "identifier_set",
72        }
73    }
74}
75
76/// The verdict for one proposed order.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum Decision {
79    Allow,
80    /// The CA would refuse. `retry_at_unix` is when this bucket next admits.
81    Defer {
82        bucket: Bucket,
83        retry_at_unix: i64,
84    },
85}
86
87/// One CA's published limits.
88///
89/// Per-CA because these numbers are **not** universal — they are Let's Encrypt's. A CA whose
90/// directory URL is not recognised gets no profile and no budget, rather than being held to somebody
91/// else's numbers: refusing an order a private or commercial CA would happily accept is a
92/// self-inflicted outage.
93#[derive(Debug, Clone)]
94pub struct CaProfile {
95    pub name: &'static str,
96    pub orders: Gcra,
97    pub registered_domain: Gcra,
98    pub identifier_set: Gcra,
99}
100
101impl CaProfile {
102    /// Let's Encrypt's published production limits, verified against their rate-limits page:
103    ///
104    /// | Limit | Value | Refill |
105    /// |---|---|---|
106    /// | New orders per account | 300 / 3 h | 1 per 36 s |
107    /// | New certificates per registered domain | 50 / 7 d | 1 per 202 min |
108    /// | New certificates per exact identifier set | 5 / 7 d | 1 per 34 h |
109    ///
110    /// The burst equals the limit and the emission interval is `window / limit`, which reproduces
111    /// the published refill rates exactly — that agreement is what says the model matches the CA's.
112    fn lets_encrypt() -> Result<CaProfile> {
113        Ok(CaProfile {
114            name: "letsencrypt",
115            orders: Gcra::from_parts(300, Duration::from_secs(3 * 3_600), 300)?,
116            registered_domain: Gcra::from_parts(50, Duration::from_secs(7 * 86_400), 50)?,
117            identifier_set: Gcra::from_parts(5, Duration::from_secs(7 * 86_400), 5)?,
118        })
119    }
120
121    /// Resolve a profile from the ACME directory URL.
122    ///
123    /// Staging gets the **production** profile deliberately, even though its real limits are much
124    /// higher. Staging is the compiled-in default for this crate, so an inert budget there would
125    /// mean the budget code is never exercised in the configuration most deployments start from —
126    /// untested code on the path that matters. Being conservative on a test CA costs nothing.
127    pub fn for_directory(url: &str) -> Option<CaProfile> {
128        let host = url
129            .split_once("://")
130            .map(|(_, r)| r)
131            .unwrap_or(url)
132            .split('/')
133            .next()
134            .unwrap_or("");
135        if host.ends_with("letsencrypt.org") {
136            CaProfile::lets_encrypt().ok()
137        } else {
138            None
139        }
140    }
141
142    fn gcra(&self, b: Bucket) -> &Gcra {
143        match b {
144            Bucket::Orders => &self.orders,
145            Bucket::RegisteredDomain => &self.registered_domain,
146            Bucket::IdentifierSet => &self.identifier_set,
147        }
148    }
149}
150
151/// Group a hostname for the per-registered-domain bucket.
152///
153/// The CA groups by registered domain (eTLD+1), which needs the Public Suffix List to compute
154/// exactly. This crate will not take that dependency for one bucket, so it uses the **last two
155/// labels** and is honest about the consequence:
156///
157/// - `a.b.example.com` → `example.com` — correct.
158/// - `a.example.co.uk` → `co.uk` — **wrong, and wrong in the safe direction.** It merges every
159///   `.co.uk` registered domain into one bucket, so the guard becomes stricter than the CA. It never
160///   splits one CA bucket into two, which is the only error that would let issuance through that the
161///   CA then refuses.
162///
163/// An IP literal, or a name with two or fewer labels, is returned unchanged.
164pub fn group_key(host: &str) -> String {
165    let h = host.trim().trim_end_matches('.').to_ascii_lowercase();
166    if h.is_empty() || h.parse::<std::net::IpAddr>().is_ok() {
167        return h;
168    }
169    let labels: Vec<&str> = h.split('.').collect();
170    if labels.len() <= 2 {
171        return h;
172    }
173    labels[labels.len() - 2..].join(".")
174}
175
176/// The stable key for the exact-identifier-set bucket: the domains, lowercased, deduplicated and
177/// sorted, so the same set in a different order is the same key. The CA matches on the set, not the
178/// order it was written in.
179pub fn identifier_set_key(domains: &[String]) -> String {
180    let mut d: Vec<String> = domains
181        .iter()
182        .map(|s| s.trim().trim_end_matches('.').to_ascii_lowercase())
183        .filter(|s| !s.is_empty())
184        .collect();
185    d.sort();
186    d.dedup();
187    d.join(",")
188}
189
190/// The persisted state: one theoretical-arrival-time per bucket key, in microseconds.
191#[derive(Debug, Default, Serialize, Deserialize)]
192struct LedgerFile {
193    /// `"<bucket>:<key>" -> TAT µs`.
194    #[serde(default)]
195    tats: HashMap<String, u64>,
196}
197
198/// The issuance budget for one edge.
199///
200/// Persisted in the ACME cache directory — the same place the account key lives, and the directory a
201/// deployment must already make durable for ACME to work at all. A budget that resets on restart
202/// would be no budget: restarts are precisely the event it exists to survive.
203pub struct IssuanceBudget {
204    profile: CaProfile,
205    path: PathBuf,
206    file: LedgerFile,
207}
208
209impl IssuanceBudget {
210    /// Load (or start) the ledger for `cache_dir`. `None` when the CA is unrecognised — see
211    /// [`CaProfile::for_directory`].
212    pub fn load(directory_url: &str, cache_dir: &str) -> Option<IssuanceBudget> {
213        let profile = CaProfile::for_directory(directory_url)?;
214        let path = Path::new(cache_dir).join("issuance-budget.json");
215        let file = std::fs::read_to_string(&path)
216            .ok()
217            .and_then(|s| serde_json::from_str::<LedgerFile>(&s).ok())
218            .unwrap_or_default();
219        info!(
220            ca = profile.name,
221            ledger = %path.display(),
222            entries = file.tats.len(),
223            "ACME issuance budget active"
224        );
225        Some(IssuanceBudget {
226            profile,
227            path,
228            file,
229        })
230    }
231
232    fn key(bucket: Bucket, k: &str) -> String {
233        format!("{}:{}", bucket.label(), k)
234    }
235
236    /// Would every bucket admit an order for `domains`? Does **not** debit — see [`Self::debit`].
237    pub fn check(&self, domains: &[String], now_unix: i64) -> Decision {
238        let now_us = (now_unix.max(0) as u64).saturating_mul(1_000_000);
239        for (bucket, k) in self.keys_for(domains) {
240            let g = self.profile.gcra(bucket);
241            let stored = self.file.tats.get(&Self::key(bucket, &k)).copied();
242            if gcra_admit(stored, now_us, g).is_none() {
243                return Decision::Defer {
244                    bucket,
245                    retry_at_unix: (g.next_admit_at(stored, now_us) / 1_000_000) as i64,
246                };
247            }
248        }
249        Decision::Allow
250    }
251
252    /// Debit every bucket and persist, **before** the order is sent.
253    ///
254    /// Ordering is the whole point. An order that reaches the CA may have been counted by it even if
255    /// the response never arrives, so a debit that happened only on success would let a failing loop
256    /// spend the real allowance while the local ledger showed it untouched. There is deliberately no
257    /// refund on failure, for the same reason.
258    pub fn debit(&mut self, domains: &[String], now_unix: i64) -> Result<()> {
259        let now_us = (now_unix.max(0) as u64).saturating_mul(1_000_000);
260        for (bucket, k) in self.keys_for(domains) {
261            let g = self.profile.gcra(bucket);
262            let key = Self::key(bucket, &k);
263            let stored = self.file.tats.get(&key).copied();
264            if let Some(new_tat) = gcra_admit(stored, now_us, g) {
265                self.file.tats.insert(key, new_tat);
266            }
267        }
268        self.persist()
269    }
270
271    fn keys_for(&self, domains: &[String]) -> Vec<(Bucket, String)> {
272        let mut out = vec![
273            (Bucket::Orders, "account".to_string()),
274            (Bucket::IdentifierSet, identifier_set_key(domains)),
275        ];
276        // One entry per distinct registered domain in the request.
277        let mut groups: Vec<String> = domains.iter().map(|d| group_key(d)).collect();
278        groups.sort();
279        groups.dedup();
280        for g in groups {
281            out.push((Bucket::RegisteredDomain, g));
282        }
283        out
284    }
285
286    /// Write the ledger atomically: a torn file would be unparseable and silently reset the budget
287    /// to empty, which is the one failure mode that looks like everything is fine.
288    fn persist(&self) -> Result<()> {
289        if let Some(dir) = self.path.parent() {
290            std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
291        }
292        let tmp = self.path.with_extension("json.tmp");
293        let body = serde_json::to_string(&self.file).context("serialising the issuance ledger")?;
294        std::fs::write(&tmp, body).with_context(|| format!("writing {}", tmp.display()))?;
295        std::fs::rename(&tmp, &self.path)
296            .with_context(|| format!("renaming into {}", self.path.display()))?;
297        Ok(())
298    }
299
300    /// Remaining admissions per bucket, for metrics and for the operator's warning before
301    /// exhaustion rather than after.
302    pub fn remaining(&self, domains: &[String], now_unix: i64) -> Vec<(Bucket, String, u64)> {
303        let now_us = (now_unix.max(0) as u64).saturating_mul(1_000_000);
304        self.keys_for(domains)
305            .into_iter()
306            .map(|(bucket, k)| {
307                let stored = self.file.tats.get(&Self::key(bucket, &k)).copied();
308                let left = self.profile.gcra(bucket).remaining(stored, now_us);
309                (bucket, k, left)
310            })
311            .collect()
312    }
313
314    pub fn ca_name(&self) -> &'static str {
315        self.profile.name
316    }
317}
318
319/// Unix seconds now.
320pub fn now_unix() -> i64 {
321    SystemTime::now()
322        .duration_since(UNIX_EPOCH)
323        .map(|d| d.as_secs() as i64)
324        .unwrap_or(0)
325}
326
327/// Render the budget as Prometheus text, so an operator sees it draining rather than gone.
328///
329/// Deliberately a gauge per bucket rather than a single "is it exhausted" flag: the alert that
330/// matters fires at 60% consumed, days before the outage, and a boolean cannot say that.
331///
332/// The identifier-set key is a tenant's full hostname list, so it is **hashed** into the label.
333/// Putting it in plaintext would publish the customer's domain names to whoever can read
334/// `/metrics`; the mapping stays behind the authenticated API.
335pub fn render_metrics(budget: &IssuanceBudget, domains: &[String], now_unix: i64) -> String {
336    let mut out = String::new();
337    // The help text names the scope, because the number is easy to misread as a fleet total and an
338    // operator would size a rollout against it. The registered_domain bucket in particular is
339    // SHARED with every other edge under that domain; this gauge only knows what this edge spent.
340    out.push_str(
341        "# HELP edgeguard_acme_budget_remaining Issuance THIS EDGE's own ledger still allows, per bucket. Not a fleet total: the registered_domain limit is shared across every edge under that domain, and the control plane's GET /v3/acme/budget is the fleet figure.\n",
342    );
343    out.push_str("# TYPE edgeguard_acme_budget_remaining gauge\n");
344    for (bucket, key, left) in budget.remaining(domains, now_unix) {
345        let label = match bucket {
346            Bucket::IdentifierSet => short_hash(&key),
347            _ => key.clone(),
348        };
349        out.push_str(&format!(
350            "edgeguard_acme_budget_remaining{{ca=\"{}\",bucket=\"{}\",key=\"{}\"}} {left}\n",
351            budget.ca_name(),
352            bucket.label(),
353            escape_label(&label),
354        ));
355    }
356    out
357}
358
359/// A short, stable, non-reversible label for a hostname set.
360fn short_hash(s: &str) -> String {
361    // FNV-1a, 64-bit. Not a cryptographic commitment — it only has to be stable and to not be the
362    // customer's domain list in plaintext.
363    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
364    for b in s.as_bytes() {
365        h ^= *b as u64;
366        h = h.wrapping_mul(0x1000_0000_01b3);
367    }
368    format!("{h:016x}")
369}
370
371/// Prometheus label values may not carry a raw `"` or `\`.
372fn escape_label(s: &str) -> String {
373    s.replace('\\', "\\\\").replace('"', "\\\"")
374}
375
376/// Log a deferral in the form an operator can act on.
377pub fn warn_deferred(bucket: Bucket, retry_at_unix: i64, domains: &[String]) {
378    warn!(
379        bucket = bucket.label(),
380        retry_at_unix,
381        domains = ?domains,
382        "ACME issuance deferred: the CA's rate limit for this bucket is exhausted. \
383         The existing certificate (if any) keeps serving; no self-signed certificate is \
384         substituted on a public name."
385    );
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    fn budget(dir: &Path) -> IssuanceBudget {
393        IssuanceBudget::load(
394            "https://acme-v02.api.letsencrypt.org/directory",
395            dir.to_str().unwrap(),
396        )
397        .expect("letsencrypt profile")
398    }
399
400    fn tmpdir(tag: &str) -> PathBuf {
401        let d = std::env::temp_dir().join(format!("eg-acme-budget-{tag}-{}", std::process::id()));
402        let _ = std::fs::remove_dir_all(&d);
403        std::fs::create_dir_all(&d).unwrap();
404        d
405    }
406
407    #[test]
408    fn the_identifier_set_bucket_stops_a_restart_loop_at_five() {
409        // THE failure this module exists for. An edge whose cert cache is not durable re-orders the
410        // same identifier set on every boot; the CA allows five per week and then refuses for a
411        // week. The sixth attempt must be refused HERE, before the CA counts it.
412        let d = tmpdir("restart");
413        let mut b = budget(&d);
414        let domains = vec!["edge.example.com".to_string()];
415        let now = 1_800_000_000;
416
417        for i in 0..5 {
418            assert_eq!(
419                b.check(&domains, now),
420                Decision::Allow,
421                "order {i} must be allowed"
422            );
423            b.debit(&domains, now).unwrap();
424        }
425        match b.check(&domains, now) {
426            Decision::Defer {
427                bucket,
428                retry_at_unix,
429            } => {
430                assert_eq!(bucket, Bucket::IdentifierSet);
431                assert!(retry_at_unix > now, "a deferral must say when to retry");
432            }
433            Decision::Allow => panic!("the sixth order must be deferred"),
434        }
435    }
436
437    #[test]
438    fn the_budget_survives_a_restart() {
439        // A budget that resets on restart is no budget: restarts are the event it exists to
440        // survive, and its ledger lives in the ACME cache dir for exactly that reason.
441        let d = tmpdir("persist");
442        let domains = vec!["edge.example.com".to_string()];
443        let now = 1_800_000_000;
444        {
445            let mut b = budget(&d);
446            for _ in 0..5 {
447                b.debit(&domains, now).unwrap();
448            }
449        }
450        // A fresh process, same cache directory.
451        let b2 = budget(&d);
452        assert!(
453            matches!(b2.check(&domains, now), Decision::Defer { .. }),
454            "the ledger must be read back from disk"
455        );
456    }
457
458    #[test]
459    fn the_bucket_refills_over_time_rather_than_resetting_at_a_boundary() {
460        // Let's Encrypt publishes these as token buckets with refill rates, not fixed windows. A
461        // fixed-window model believes the whole allowance returns at a boundary and permits a burst
462        // the CA refuses — wrong in the dangerous direction.
463        let d = tmpdir("refill");
464        let mut b = budget(&d);
465        let domains = vec!["edge.example.com".to_string()];
466        let now = 1_800_000_000;
467        for _ in 0..5 {
468            b.debit(&domains, now).unwrap();
469        }
470        assert!(matches!(b.check(&domains, now), Decision::Defer { .. }));
471
472        // The identifier-set bucket refills one per emission interval, which is exactly
473        // 7 days / 5 = 120 960 s = 33.6 h. Let's Encrypt publishes this as "1 every 34 hours",
474        // rounded; the model uses the exact quotient, which is what reproduces their limit over a
475        // full window rather than drifting by 0.4 h per token.
476        const EI: i64 = 7 * 86_400 / 5;
477        assert!(matches!(
478            b.check(&domains, now + EI - 60),
479            Decision::Defer { .. }
480        ));
481        assert_eq!(b.check(&domains, now + EI + 60), Decision::Allow);
482        // And one refill is one token, not a reset: a second order at the same instant is refused.
483        b.debit(&domains, now + EI + 60).unwrap();
484        assert!(matches!(
485            b.check(&domains, now + EI + 60),
486            Decision::Defer { .. }
487        ));
488    }
489
490    #[test]
491    fn a_different_identifier_set_has_its_own_bucket() {
492        let d = tmpdir("sets");
493        let mut b = budget(&d);
494        let now = 1_800_000_000;
495        let a = vec!["a.example.com".to_string()];
496        for _ in 0..5 {
497            b.debit(&a, now).unwrap();
498        }
499        assert!(matches!(b.check(&a, now), Decision::Defer { .. }));
500        // A different set under the same registered domain still has 45 of the domain bucket left.
501        let c = vec!["b.example.com".to_string()];
502        assert_eq!(b.check(&c, now), Decision::Allow);
503    }
504
505    #[test]
506    fn the_registered_domain_bucket_binds_across_different_hostnames() {
507        let d = tmpdir("domain");
508        let mut b = budget(&d);
509        let now = 1_800_000_000;
510        // 50 distinct hostnames under one registered domain exhausts the 50/7d domain bucket, even
511        // though each identifier set is used once.
512        for i in 0..50 {
513            let h = vec![format!("h{i}.example.com")];
514            assert_eq!(b.check(&h, now), Decision::Allow, "host {i}");
515            b.debit(&h, now).unwrap();
516        }
517        let next = vec!["h50.example.com".to_string()];
518        match b.check(&next, now) {
519            Decision::Defer { bucket, .. } => assert_eq!(bucket, Bucket::RegisteredDomain),
520            Decision::Allow => panic!("the 51st registered-domain order must be deferred"),
521        }
522        // A different registered domain is unaffected.
523        assert_eq!(b.check(&["x.other.com".to_string()], now), Decision::Allow);
524    }
525
526    #[test]
527    fn identifier_set_key_is_order_and_case_insensitive() {
528        // The CA matches the SET. Treating a reordering as a new set would let a caller bypass the
529        // bucket entirely by shuffling its SAN list.
530        let a = identifier_set_key(&["b.example.com".into(), "A.example.com".into()]);
531        let b = identifier_set_key(&["a.example.com".into(), "B.EXAMPLE.COM.".into()]);
532        assert_eq!(a, b);
533        // ...and a genuinely different set is a different key.
534        assert_ne!(a, identifier_set_key(&["a.example.com".into()]));
535    }
536
537    #[test]
538    fn group_key_errs_toward_merging_never_splitting() {
539        // Splitting one CA bucket into two would let issuance through that the CA then refuses —
540        // the only error direction that matters. Merging is merely stricter than necessary.
541        assert_eq!(group_key("a.b.example.com"), "example.com");
542        assert_eq!(group_key("example.com"), "example.com");
543        assert_eq!(group_key("EXAMPLE.COM."), "example.com");
544        // The known-wrong case, wrong safely: every .co.uk shares one bucket.
545        assert_eq!(group_key("a.example.co.uk"), "co.uk");
546        assert_eq!(group_key("b.other.co.uk"), "co.uk");
547        // Literals and short names pass through.
548        assert_eq!(group_key("127.0.0.1"), "127.0.0.1");
549        assert_eq!(group_key("localhost"), "localhost");
550    }
551
552    #[test]
553    fn an_unrecognised_ca_gets_no_budget() {
554        // Holding a private or commercial CA to Let's Encrypt's numbers would refuse orders it
555        // would have accepted — a self-inflicted outage from a guard.
556        let d = tmpdir("unknown");
557        assert!(
558            IssuanceBudget::load("https://acme.internal/directory", d.to_str().unwrap()).is_none()
559        );
560        assert!(CaProfile::for_directory("https://ca.example.com/dir").is_none());
561        // Staging IS budgeted, deliberately: it is the compiled-in default, so leaving it inert
562        // would mean this code is never exercised in the configuration most deployments start from.
563        assert!(
564            CaProfile::for_directory("https://acme-staging-v02.api.letsencrypt.org/directory")
565                .is_some()
566        );
567    }
568
569    #[test]
570    fn metrics_never_publish_the_customers_hostname_set() {
571        // /metrics is commonly scraped by something less privileged than the API. The identifier-set
572        // key is the tenant's full hostname list and must not appear there in plaintext.
573        let d = tmpdir("metrics");
574        let b = budget(&d);
575        let domains = vec!["secret-customer.example.com".to_string()];
576        let text = render_metrics(&b, &domains, 1_800_000_000);
577        assert!(
578            !text.contains("secret-customer"),
579            "the hostname set leaked into /metrics:\n{text}"
580        );
581        // The registered domain IS published — it is the bucket the CA groups by, and an operator
582        // cannot act on the alert without it.
583        assert!(
584            text.contains("bucket=\"registered_domain\",key=\"example.com\""),
585            "{text}"
586        );
587        assert!(text.contains("edgeguard_acme_budget_remaining"), "{text}");
588    }
589
590    #[test]
591    fn remaining_counts_down_and_is_reported_per_bucket() {
592        let d = tmpdir("remaining");
593        let mut b = budget(&d);
594        let domains = vec!["edge.example.com".to_string()];
595        let now = 1_800_000_000;
596        let before = b.remaining(&domains, now);
597        let set_before = before
598            .iter()
599            .find(|(bk, _, _)| *bk == Bucket::IdentifierSet)
600            .unwrap()
601            .2;
602        assert_eq!(set_before, 5);
603        b.debit(&domains, now).unwrap();
604        let after = b.remaining(&domains, now);
605        let set_after = after
606            .iter()
607            .find(|(bk, _, _)| *bk == Bucket::IdentifierSet)
608            .unwrap()
609            .2;
610        assert_eq!(
611            set_after, 4,
612            "an operator must see the budget draining before it is gone"
613        );
614    }
615}