monitor.cert-expiry
How long a TLS certificate has left before its notAfter, and whether that is ok, a warning, critical or expired.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 15 tests, run in TypeScript, Python and Rust.
What it does
How long a TLS certificate has left, and whether to worry: `ok`, `warning`, `critical` or `expired`. The caller reads `notAfter` from the certificate (as Unix seconds); this decides what it means today.
## Decisions
For example
cert_expiry(1,767,225,600, 1,762,041,600, 30, 7)→ state ok, seconds left 5,184,000, days left 60 sixty days left is okcert_expiry(1,767,225,600, 1,764,633,600, 30, 7)→ state ok, seconds left 2,592,000, days left 30 exactly thirty days left is not fewer than thirty: still okcert_expiry(1,767,225,600, 1,764,633,601, 30, 7)→ state warning, seconds left 2,591,999, days left 29 one second under thirty days is a warning
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
pub fn cert_expiry(not_after: i64, now: i64, warn_days: i64, critical_days: i64) -> CertExpiry
| not_after | int | the certificate's notAfter in Unix seconds; it is still valid during that second |
| now | int | Unix seconds |
| warn_days | int | warn when fewer than this many days are left, at least criticalDays |
| critical_days | int | critical when fewer than this many days are left, 0 or more |
| returns | CertExpiry |
The types it declares, generated into your project
// CertExpiryState is a string in Rust, one of: "ok", "warning", "critical", "expired".
// Parameters take it as &str and results hold it as String.
/// How close a certificate is to expiring.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CertExpiry {
pub state: String,
/// notAfter - now while valid, 0 once expired
pub seconds_left: i64,
/// whole days left: floor(secondsLeft / 86400)
pub days_left: i64,
}
Your code names it in one line, in the file that uses it
fune!(monitor.cert-expiry@^1); // then call cert_expiry(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
/// RFC 5280 makes validity inclusive of notAfter, so a certificate is expired
/// only after that second, not at it. Thresholds compare whole days left with
/// the day counts: fewer than N days (secondsLeft < N * 86400) is exactly
/// floor(secondsLeft / 86400) < N, with no multiplication to overflow.
///
/// # Panics
/// Panics when critical_days is negative or warn_days is below critical_days.
pub fn cert_expiry(not_after: i64, now: i64, warn_days: i64, critical_days: i64) -> CertExpiry {
if critical_days < 0 {
panic!("criticalDays must be a whole number 0 or more, received {}", critical_days);
}
if warn_days < critical_days {
panic!("warnDays must be a whole number at least criticalDays ({}), received {}", critical_days, warn_days);
}
if now > not_after {
return CertExpiry { state: "expired".to_string(), seconds_left: 0, days_left: 0 };
}
let seconds_left = not_after - now;
let days_left = seconds_left / 86400;
let state = if days_left < critical_days {
"critical"
} else if days_left < warn_days {
"warning"
} else {
"ok"
};
CertExpiry { state: state.to_string(), seconds_left, days_left }
}
pub fn cert_expiry_to_value(c: &CertExpiry) -> Value {
Value::obj(vec![
("state", Value::str(&c.state)),
("secondsLeft", Value::Int(c.seconds_left)),
("daysLeft", Value::Int(c.days_left)),
])
}
fn show(v: &Value) -> String {
match v {
Value::Int(i) => i.to_string(),
Value::Float(f) => f.to_string(),
_ => "a non-number".to_string(),
}
}
pub fn fune_vector(args: &[Value]) -> Value {
if !matches!(args[0], Value::Int(_)) || !matches!(args[1], Value::Int(_)) {
panic!("notAfter and now must be whole seconds, received {} and {}", show(&args[0]), show(&args[1]));
}
let critical = match &args[3] {
Value::Int(i) => *i,
other => panic!("criticalDays must be a whole number 0 or more, received {}", show(other)),
};
let warn = match &args[2] {
Value::Int(i) => *i,
other => panic!("warnDays must be a whole number at least criticalDays ({}), received {}", critical, show(other)),
};
cert_expiry_to_value(&cert_expiry(args[0].as_i64(), args[1].as_i64(), warn, critical))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add monitor.cert-expiry
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./monitor.cert-expiry-1.0.0-rust.fune, or fetch it from a terminal with fune pull monitor.cert-expiry@1.0.0:rust.
The whole function, every language, is one file too: monitor.cert-expiry-1.0.0.fune, 12,501 bytes, sha256 292a46615a892497895595a25acb9d91861d2afa190688786e5cd8eca8dd9720. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before monitor.cert-expiry
after — your function gets the result and the arguments, and returns the final result.
// fune: after monitor.cert-expiry
replace — it requires no other capability, so there is no dependency to replace.
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show monitor.cert-expiry --steps.
// fune: step monitor.cert-expiry after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| sixty days left is ok | 1,767,225,600, 1,762,041,600, 30, 7 | → | state ok, seconds left 5,184,000, days left 60 |
| exactly thirty days left is not fewer than thirty: still ok | 1,767,225,600, 1,764,633,600, 30, 7 | → | state ok, seconds left 2,592,000, days left 30 |
| one second under thirty days is a warning | 1,767,225,600, 1,764,633,601, 30, 7 | → | state warning, seconds left 2,591,999, days left 29 |
| exactly seven days left is a warning, not critical | 1,767,225,600, 1,766,620,800, 30, 7 | → | state warning, seconds left 604,800, days left 7 |
| one second under seven days is critical | 1,767,225,600, 1,766,620,801, 30, 7 | → | state critical, seconds left 604,799, days left 6 |
| the notAfter second itself is still valid (RFC 5280: inclusive) | 1,767,225,600, 1,767,225,600, 30, 7 | → | state critical, seconds left 0, days left 0 |
| one second after notAfter is expired | 1,767,225,600, 1,767,225,601, 30, 7 | → | state expired, seconds left 0, days left 0 |
| long expired shows zero, never negative | 1,767,225,600, 1,853,625,600, 30, 7 | → | state expired, seconds left 0, days left 0 |
| with no thresholds the last second is ok | 1,767,225,600, 1,767,225,599, 0, 0 | → | state ok, seconds left 1, days left 0 |
| equal warn and critical days: never a warning, straight to critical | 1,767,225,600, 1,766,361,600, 14, 14 | → | state critical, seconds left 864,000, days left 10 |
Show the other 5 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| half a day is zero whole days, critical | 1,767,225,600, 1,767,182,400, 30, 1 | → | state critical, seconds left 43,200, days left 0 |
| the RFC 5280 no-expiry notAfter 99991231235959Z is ok for millions of days | 253,402,300,799, 1,767,225,600, 30, 7 | → | state ok, seconds left 251,635,075,199, days left 2,912,442 |
| a negative criticalDays is an error | 1,767,225,600, 1,767,225,600, 30, -1 | → | error: criticalDays must be a whole number 0 or more, received -1 |
| warnDays below criticalDays is an error | 1,767,225,600, 1,767,225,600, 7, 14 | → | error: warnDays must be a whole number at least criticalDays (14), received 7 |
| a fractional now is an error | 1,767,225,600, 1.5, 30, 7 | → | error: notAfter and now must be whole seconds, received 1767225600 and 1.5 |
More from the author
- **notAfter is inclusive.** RFC 5280 section 4.1.2.5: "The validity period for a certificate is the period of time from notBefore through notAfter, inclusive." So the certificate is still valid during the notAfter second and is `expired` only when `now > notAfter`. At `now == notAfter`, `secondsLeft` is 0 but the state is not yet expired. - **Thresholds are "fewer than N days"**: critical when `secondsLeft < criticalDays * 86400`, else warning when `secondsLeft < warnDays * 86400`, else ok. Exactly 30 days left with a 30-day warning is still ok. This is computed as `floor(secondsLeft / 86400) < N`, which is the same test with nothing to overflow. - `daysLeft` is whole days, rounded down: 6 days 23 hours is 6. - `secondsLeft` and `daysLeft` are 0 once expired, never negative. - `warnDays` equal to `criticalDays` means no warning stage. With `criticalDays` 0 there is no critical stage, and the last valid second is `ok`; pick at least 1 if you want to hear about it. - The usual settings are 30 and 7 days (Let's Encrypt certificates last 90 days and are renewed at 30 left), or 14 and 3. - `99991231235959Z` (253402300799), which RFC 5280 says marks a certificate with no well-defined expiry, is simply a very distant date and reads `ok`.
## Errors
- `criticalDays must be a whole number 0 or more, received X` - `warnDays must be a whole number at least criticalDays (C), received X` - `notAfter and now must be whole seconds, received A and B`
## Sources
- RFC 5280, Internet X.509 Public Key Infrastructure Certificate and CRL Profile, section 4.1.2.5 Validity: https://www.rfc-editor.org/rfc/rfc5280#section-4.1.2.5
Files
| Path | Bytes |
|---|---|
| README.md | 1,908 |
| impl/python.py | 1,457 |
| impl/rust.rs | 2,344 |
| impl/typescript.ts | 1,339 |
| vectors.json | 2,598 |