sup_xml_core/license_gate.rs
1//! Process-wide license gate.
2//!
3//! sup-xml requires a valid license certificate to parse documents.
4//! The verification — locating a certificate, two signature checks, and
5//! a JSON parse — runs **once per process**, lazily on the first parse,
6//! and the verdict is cached for the process lifetime. Every subsequent
7//! parse pays only an atomic load, so the gate adds no measurable
8//! per-call overhead.
9//!
10//! Call [`verify_license`] at startup to verify eagerly and fail fast;
11//! otherwise the first document parse triggers the check.
12//!
13//! The certificate is located the way `supso_project` describes: the
14//! `SUPSO_LICENSE_PATH` environment variable, then
15//! `$HOME/.supso/license_certificates/`, then `./.supso/license_certificates/`.
16
17use crate::error::{ErrorDomain, ErrorLevel, Result, XmlError};
18use chrono::{DateTime, TimeZone, Utc};
19use std::sync::OnceLock;
20use supso_project::{Enforcement, Status, Supso};
21
22/// The Supso project slug this build is licensed against. The certificate's
23/// signed product binding must match this exactly.
24const PROJECT_SLUG: &str = "sup-xml";
25
26/// Cached verdict: `Ok(())` once a valid license has been seen, or the
27/// human-readable reason it failed. `String` (not the borrowed
28/// `supso_project::Error`) so it can live in a `'static` cache.
29static VERDICT: OnceLock<std::result::Result<(), String>> = OnceLock::new();
30
31/// `DateTime<Utc>` from `SystemTime` without pulling in `iana-time-zone`
32/// (the chrono `clock` feature does). A library must not drag in OS-level
33/// timezone resolution it doesn't need.
34fn now() -> DateTime<Utc> {
35 let dur = std::time::SystemTime::now()
36 .duration_since(std::time::UNIX_EPOCH)
37 .unwrap_or_default();
38 DateTime::<Utc>::from_timestamp(dur.as_secs() as i64, dur.subsec_nanos())
39 .unwrap_or_else(|| Utc.timestamp_opt(0, 0).unwrap())
40}
41
42fn verdict() -> &'static std::result::Result<(), String> {
43 VERDICT.get_or_init(|| {
44 let at = now();
45 // `Silent`: this gate routes its grace notice through `log` so it
46 // lands in the host's tracker, rather than letting the library
47 // write to stderr unbidden.
48 let status = Supso::project(PROJECT_SLUG)
49 .enforcement(Enforcement::Silent)
50 .check_at(at);
51 match status {
52 Status::Valid(_) => Ok(()),
53 // A certificate honoured under the post-expiry grace period
54 // still licenses the process, but the lapse is logged at
55 // `error` so it lands in the host's tracker. The day counts
56 // in the message change daily, so a fresh alert fires each
57 // day the lapse persists rather than one that is silenced
58 // after first sight.
59 Status::Grace { .. } => {
60 if let Some(notice) = status.grace_message(at) {
61 log::error!("{notice}");
62 }
63 Ok(())
64 }
65 Status::Unlicensed(e) => Err(e.to_string()),
66 }
67 })
68}
69
70/// Verify the license now, caching the result, and return an error if no
71/// valid, non-expired certificate is present.
72///
73/// Parsing performs this check lazily on first use regardless; calling
74/// this at program startup surfaces a missing or expired license
75/// immediately rather than at the first parse.
76pub fn verify_license() -> Result<()> {
77 ensure_licensed()
78}
79
80/// The gate every document-parse entry point calls. Cheap after the
81/// first invocation (an atomic load plus a match).
82pub(crate) fn ensure_licensed() -> Result<()> {
83 match verdict() {
84 Ok(()) => Ok(()),
85 Err(reason) => Err(XmlError::new(
86 ErrorDomain::None,
87 ErrorLevel::Fatal,
88 format!(
89 "sup-xml: a valid license is required to parse documents — {reason}. \
90 Get a certificate (free for individuals and 30-day evaluation) at \
91 https://supso.org/projects/sup-xml"
92 ),
93 )),
94 }
95}