Skip to main content

fallow_license/
lib.rs

1//! Offline Ed25519-signed license JWT verification for the fallow CLI.
2//!
3//! This crate is the public-binary side of fallow's paid-feature gating. It
4//! does NOT perform any network I/O; the license file is loaded from disk or
5//! environment, the signature is verified against a public key compiled in by
6//! the embedding binary, and the result is exposed as a [`LicenseStatus`].
7//!
8//! # Storage precedence
9//!
10//! License material is sourced in this order (first match wins):
11//!
12//! 1. `$FALLOW_LICENSE` environment variable (full JWT string).
13//! 2. `$FALLOW_LICENSE_PATH` environment variable (path to a file containing the JWT).
14//! 3. `~/.fallow/license.jwt` (default path under the user's home directory).
15//!
16//! # Algorithm pinning
17//!
18//! Only Ed25519 (`EdDSA`) is accepted. The JWT header's `alg` claim is verified
19//! to equal `"EdDSA"` *after* base64 decoding; we never trust the header to pick
20//! the algorithm.
21//!
22//! # Grace ladder
23//!
24//! Matches Docker Desktop / JetBrains conventions. See [`grace_state`].
25
26#![forbid(unsafe_code)]
27#![cfg_attr(
28    test,
29    allow(
30        clippy::unwrap_used,
31        clippy::expect_used,
32        reason = "tests use unwrap and expect to keep fixture setup concise"
33    )
34)]
35
36use std::path::{Path, PathBuf};
37use std::time::{SystemTime, UNIX_EPOCH};
38
39use base64::Engine;
40use base64::engine::general_purpose::URL_SAFE_NO_PAD;
41use ed25519_dalek::{Signature, VerifyingKey};
42use serde::{Deserialize, Serialize};
43
44/// Default cap on the grace window before hard-fail in the public CLI.
45///
46/// The enterprise binary (`--features enterprise-license`) lifts this cap.
47pub const DEFAULT_HARD_FAIL_DAYS: u64 = 30;
48
49/// Days post-expiry after which the public output gains a visible watermark.
50pub const WATERMARK_DAYS: u64 = 7;
51
52/// Default tolerance (in seconds) for `iat` clock skew: 24h.
53///
54/// Matches the leeway defaults used by `jsonwebtoken` (Node),
55/// `pyjwt`, and `jjwt`. A JWT whose `iat` is more than this many seconds in
56/// the future relative to the local clock is rejected as
57/// [`LicenseError::ClockSkew`]. Override via
58/// `FALLOW_LICENSE_SKEW_TOLERANCE_SECONDS` (consumed by
59/// [`skew_tolerance_seconds_from_env`]).
60pub const DEFAULT_SKEW_TOLERANCE_SECONDS: i64 = 86_400;
61
62/// Env var name for overriding [`DEFAULT_SKEW_TOLERANCE_SECONDS`].
63pub const SKEW_TOLERANCE_ENV: &str = "FALLOW_LICENSE_SKEW_TOLERANCE_SECONDS";
64
65/// JWT claims emitted by `api.fallow.cloud` for fallow CLI licenses.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct LicenseClaims {
68    /// Issuer (typically `"https://api.fallow.cloud"`).
69    pub iss: String,
70    /// Subject — opaque org identifier.
71    pub sub: String,
72    /// Tenant identifier.
73    pub tid: String,
74    /// Number of seats licensed.
75    pub seats: u32,
76    /// Tier string emitted by fallow-cloud: `pro`, `enterprise`, `trial`, `founding`.
77    /// (`team` is the legacy name for `pro`; the server now emits `pro`.) The
78    /// value is informational only: capability gating is on `features`, never on
79    /// this string, so any tier value is tolerated.
80    pub tier: String,
81    /// Feature flags. Modeled as strings on the wire for forward-compat;
82    /// callers convert to [`Feature`] for matching.
83    pub features: Vec<String>,
84    /// Issued-at, seconds since UNIX epoch.
85    pub iat: i64,
86    /// Expiration, seconds since UNIX epoch.
87    pub exp: i64,
88    /// Unique JWT ID (used for refresh + revocation).
89    pub jti: String,
90    /// Suggested refresh timestamp, seconds since UNIX epoch. Backend emits
91    /// this at `iat + 15 days` so CI runs can proactively refresh before the
92    /// hard-fail window. `None` when the backend did not include the claim
93    /// (older license payloads or third-party issuers).
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub refresh_after: Option<i64>,
96}
97
98/// Feature flag enum aligned with the protocol's `Feature` strings.
99///
100/// Wire format stays a string array; new variants are additive in minor protocol
101/// bumps and unrecognized strings round-trip through [`Feature::Other`].
102#[derive(Debug, Clone, PartialEq, Eq, Hash)]
103pub enum Feature {
104    /// Paid local runtime coverage analyzer (CLI + sidecar).
105    RuntimeCoverage,
106    /// Cloud portfolio dashboard. Currently inert: granted in JWTs but not
107    /// yet consumed by any CLI command.
108    PortfolioDashboard,
109    /// Cloud MCP tools. Currently inert: granted in JWTs but not yet
110    /// consumed by any CLI command.
111    McpCloudTools,
112    /// Cross-repo aggregation. Currently inert: granted in JWTs but not yet
113    /// consumed by any CLI command.
114    CrossRepoAggregation,
115    /// Forward-compat sentinel for unrecognized feature strings.
116    Other(String),
117}
118
119impl Feature {
120    /// Parse a wire string into a [`Feature`]. Unrecognized strings round-trip
121    /// through [`Feature::Other`] so older CLIs do not error on newer license
122    /// payloads.
123    #[must_use]
124    pub fn parse(s: &str) -> Self {
125        match s {
126            "runtime_coverage" => Self::RuntimeCoverage,
127            "portfolio_dashboard" => Self::PortfolioDashboard,
128            "mcp_cloud_tools" => Self::McpCloudTools,
129            "cross_repo_aggregation" => Self::CrossRepoAggregation,
130            other => Self::Other(other.to_owned()),
131        }
132    }
133}
134
135impl LicenseClaims {
136    /// True if the license's `features` claim contains the requested feature.
137    #[must_use]
138    pub fn has_feature(&self, feature: &Feature) -> bool {
139        self.features.iter().any(|s| Feature::parse(s) == *feature)
140    }
141}
142
143/// Outcome of [`load_and_verify`].
144#[derive(Debug, Clone)]
145pub enum LicenseStatus {
146    /// License is valid and not yet expired.
147    Valid {
148        claims: LicenseClaims,
149        days_until_expiry: i64,
150    },
151    /// License is in the warning window (0..[`WATERMARK_DAYS`] days post-expiry).
152    /// Analysis runs normally; human output prints a refresh hint.
153    ExpiredWarning {
154        claims: LicenseClaims,
155        days_since_expiry: u64,
156    },
157    /// License is in the watermark window
158    /// ([`WATERMARK_DAYS`]..hard_fail_days post-expiry). Analysis runs but
159    /// every human-facing surface gains a visible "license expired" watermark.
160    ExpiredWatermark {
161        claims: LicenseClaims,
162        days_since_expiry: u64,
163    },
164    /// License is past the hard-fail cap. Analysis must NOT run.
165    HardFail {
166        claims: LicenseClaims,
167        days_since_expiry: u64,
168    },
169    /// No license material was found at any of the precedence locations.
170    Missing,
171}
172
173impl LicenseStatus {
174    /// True if the holder is allowed to use paid features (any non-hard-fail
175    /// state with the requested feature in the claims).
176    #[must_use]
177    pub fn permits(&self, feature: &Feature) -> bool {
178        match self {
179            Self::Valid { claims, .. }
180            | Self::ExpiredWarning { claims, .. }
181            | Self::ExpiredWatermark { claims, .. } => claims.has_feature(feature),
182            Self::HardFail { .. } | Self::Missing => false,
183        }
184    }
185
186    /// True if a watermark string should be appended to user-facing output.
187    #[must_use]
188    pub const fn show_watermark(&self) -> bool {
189        matches!(self, Self::ExpiredWatermark { .. })
190    }
191}
192
193/// Errors returned by [`load_and_verify`] when the license material is present
194/// but malformed (vs simply missing, which is reported via [`LicenseStatus::Missing`]).
195#[derive(Debug)]
196pub enum LicenseError {
197    /// I/O error reading the license file.
198    Io(std::io::Error),
199    /// JWT structure was not three base64url-encoded segments.
200    MalformedJwt(String),
201    /// Header could not be parsed as JSON or had wrong `alg`.
202    BadHeader(String),
203    /// Payload could not be parsed as [`LicenseClaims`].
204    BadPayload(String),
205    /// Signature verification failed.
206    BadSignature,
207    /// JWT length looks truncated (typical valid range 700-1500 chars).
208    Truncated { actual: usize },
209    /// The license JWT's `iat` claim is more than the configured tolerance in
210    /// the future relative to the local clock. Mathematically equivalent to
211    /// "the local clock is more than the tolerance behind the license issue
212    /// time"; the two interpretations are the same condition.
213    ///
214    /// Tolerance is applied only to `iat`, not to `exp`. The existing grace
215    /// ladder (7 / 30 / hard-fail) absorbs sub-day `exp` skew. This is a
216    /// deliberate asymmetry; revisit if a real incident shows otherwise.
217    ClockSkew {
218        /// JWT `iat` claim (unix seconds).
219        iat_seconds: i64,
220        /// Local clock at verification time (unix seconds).
221        now_seconds: i64,
222        /// Tolerance window applied (seconds).
223        tolerance_seconds: i64,
224    },
225}
226
227impl std::fmt::Display for LicenseError {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        match self {
230            Self::Io(err) => write!(f, "license I/O error: {err}"),
231            Self::MalformedJwt(msg) => write!(f, "malformed JWT: {msg}"),
232            Self::BadHeader(msg) => write!(f, "bad JWT header: {msg}"),
233            Self::BadPayload(msg) => write!(f, "bad JWT payload: {msg}"),
234            Self::BadSignature => write!(f, "JWT signature verification failed"),
235            Self::Truncated { actual } => write!(
236                f,
237                "the token looks truncated (got {actual} chars; expected 700+). Did you copy the whole thing? Try: fallow license activate --from-file license.jwt"
238            ),
239            Self::ClockSkew {
240                iat_seconds,
241                now_seconds,
242                tolerance_seconds,
243            } => {
244                let delta = iat_seconds.saturating_sub(*now_seconds).unsigned_abs();
245                let tolerance = u64::try_from(*tolerance_seconds).unwrap_or(0);
246                write!(
247                    f,
248                    "license appears to be issued {duration} in the future (allowed skew {tolerance_human}). The system clock and the license issue time differ significantly; this commonly happens in CI containers without NTP, on machines with a dead BIOS battery, or when a clock has drifted. After confirming your clock is correct, set {env}=<seconds> to override the default 24h window.",
249                    duration = format_duration_seconds(delta),
250                    tolerance_human = format_duration_seconds(tolerance),
251                    env = SKEW_TOLERANCE_ENV,
252                )
253            }
254        }
255    }
256}
257
258impl std::error::Error for LicenseError {}
259
260impl From<std::io::Error> for LicenseError {
261    fn from(err: std::io::Error) -> Self {
262        Self::Io(err)
263    }
264}
265
266/// Verify a raw JWT string against the supplied public key and (optionally)
267/// the wall clock. The `now` parameter is the unix-seconds reference used to
268/// classify expiry; pass [`current_unix_seconds`] in production.
269///
270/// Delegates to [`verify_jwt_with_skew`] with [`DEFAULT_SKEW_TOLERANCE_SECONDS`]
271/// so existing callers retain the same signature; new code that needs to
272/// honor the `FALLOW_LICENSE_SKEW_TOLERANCE_SECONDS` env var should call
273/// [`verify_jwt_with_skew`] directly with [`skew_tolerance_seconds_from_env`].
274pub fn verify_jwt(
275    raw_jwt: &str,
276    public_key: &VerifyingKey,
277    now: i64,
278    hard_fail_days: u64,
279) -> Result<LicenseStatus, LicenseError> {
280    verify_jwt_with_skew(
281        raw_jwt,
282        public_key,
283        now,
284        hard_fail_days,
285        DEFAULT_SKEW_TOLERANCE_SECONDS,
286    )
287}
288
289/// Verify a raw JWT string with an explicit clock-skew tolerance.
290///
291/// Rejects JWTs whose `iat` is more than `skew_tolerance_seconds` in the
292/// future relative to `now`. The same condition catches both forward-signed
293/// JWTs and systems whose clocks are behind reality (since
294/// `now < iat - tolerance` is equivalent to `iat > now + tolerance`).
295/// Tolerance is applied only to `iat`; `exp` continues to flow through the
296/// grace ladder unchanged.
297pub fn verify_jwt_with_skew(
298    raw_jwt: &str,
299    public_key: &VerifyingKey,
300    now: i64,
301    hard_fail_days: u64,
302    skew_tolerance_seconds: i64,
303) -> Result<LicenseStatus, LicenseError> {
304    let trimmed = normalize_jwt(raw_jwt);
305
306    if trimmed.len() < 200 {
307        return Err(LicenseError::Truncated {
308            actual: trimmed.len(),
309        });
310    }
311
312    let parts: Vec<&str> = trimmed.split('.').collect();
313    if parts.len() != 3 {
314        return Err(LicenseError::MalformedJwt(format!(
315            "expected 3 segments, got {}",
316            parts.len()
317        )));
318    }
319    let (header_b64, payload_b64, signature_b64) = (parts[0], parts[1], parts[2]);
320
321    let header_bytes = URL_SAFE_NO_PAD
322        .decode(header_b64)
323        .map_err(|err| LicenseError::BadHeader(format!("base64 decode: {err}")))?;
324    let header: serde_json::Value = serde_json::from_slice(&header_bytes)
325        .map_err(|err| LicenseError::BadHeader(format!("json parse: {err}")))?;
326    let alg = header
327        .get("alg")
328        .and_then(|v| v.as_str())
329        .ok_or_else(|| LicenseError::BadHeader("missing alg claim".to_owned()))?;
330    if alg != "EdDSA" {
331        return Err(LicenseError::BadHeader(format!(
332            "expected alg=EdDSA, got alg={alg}"
333        )));
334    }
335
336    let signature_bytes = URL_SAFE_NO_PAD
337        .decode(signature_b64)
338        .map_err(|_| LicenseError::BadSignature)?;
339    let signature_array: [u8; 64] = signature_bytes
340        .as_slice()
341        .try_into()
342        .map_err(|_| LicenseError::BadSignature)?;
343    let signature = Signature::from_bytes(&signature_array);
344    let signing_input = format!("{header_b64}.{payload_b64}");
345    public_key
346        .verify_strict(signing_input.as_bytes(), &signature)
347        .map_err(|_| LicenseError::BadSignature)?;
348
349    let payload_bytes = URL_SAFE_NO_PAD
350        .decode(payload_b64)
351        .map_err(|err| LicenseError::BadPayload(format!("base64 decode: {err}")))?;
352    let claims: LicenseClaims = serde_json::from_slice(&payload_bytes)
353        .map_err(|err| LicenseError::BadPayload(format!("json parse: {err}")))?;
354
355    let earliest_iat = now.saturating_add(skew_tolerance_seconds);
356    if claims.iat > earliest_iat {
357        return Err(LicenseError::ClockSkew {
358            iat_seconds: claims.iat,
359            now_seconds: now,
360            tolerance_seconds: skew_tolerance_seconds,
361        });
362    }
363
364    Ok(grace_state(claims, now, hard_fail_days))
365}
366
367/// Map a verified [`LicenseClaims`] to a [`LicenseStatus`] using the 7/cap/hard-fail
368/// ladder.
369#[must_use]
370pub fn grace_state(claims: LicenseClaims, now: i64, hard_fail_days: u64) -> LicenseStatus {
371    let delta_seconds = i64::from(claims.exp != 0) * (claims.exp - now);
372    if delta_seconds >= 0 {
373        return LicenseStatus::Valid {
374            days_until_expiry: delta_seconds / SECONDS_PER_DAY,
375            claims,
376        };
377    }
378    let days_since_expiry = (delta_seconds.unsigned_abs()).div_ceil(SECONDS_PER_DAY.unsigned_abs());
379    if days_since_expiry > hard_fail_days {
380        LicenseStatus::HardFail {
381            claims,
382            days_since_expiry,
383        }
384    } else if days_since_expiry > WATERMARK_DAYS {
385        LicenseStatus::ExpiredWatermark {
386            claims,
387            days_since_expiry,
388        }
389    } else {
390        LicenseStatus::ExpiredWarning {
391            claims,
392            days_since_expiry,
393        }
394    }
395}
396
397/// Discover and load a license JWT according to the storage precedence rules,
398/// then verify it and apply the grace ladder.
399///
400/// Returns `Ok(LicenseStatus::Missing)` when no source provides material; an
401/// `Err(LicenseError)` only when material was present but malformed.
402pub fn load_and_verify(
403    public_key: &VerifyingKey,
404    hard_fail_days: u64,
405) -> Result<LicenseStatus, LicenseError> {
406    let now = current_unix_seconds();
407    let skew = skew_tolerance_seconds_from_env();
408    match load_raw_jwt()? {
409        Some(jwt) => verify_jwt_with_skew(&jwt, public_key, now, hard_fail_days, skew),
410        None => Ok(LicenseStatus::Missing),
411    }
412}
413
414/// Resolve the JWT source according to [storage precedence](crate#storage-precedence).
415///
416/// Returns `Ok(None)` when no source provides material.
417pub fn load_raw_jwt() -> Result<Option<String>, LicenseError> {
418    if let Ok(jwt) = std::env::var("FALLOW_LICENSE") {
419        let trimmed = normalize_jwt(&jwt);
420        if !trimmed.is_empty() {
421            return Ok(Some(trimmed));
422        }
423    }
424    if let Some(path) = resolve_license_path_env(std::env::var("FALLOW_LICENSE_PATH").ok()) {
425        return Ok(Some(read_jwt_file(&path)?));
426    }
427    let default = default_license_path();
428    if default.exists() {
429        return Ok(Some(read_jwt_file(&default)?));
430    }
431    Ok(None)
432}
433
434/// Normalize a raw `$FALLOW_LICENSE_PATH` env value. Returns `None` when the
435/// var is unset, empty, or whitespace-only so the caller falls through to
436/// default-path discovery; otherwise returns the trimmed path. Without this,
437/// shells that export `FALLOW_LICENSE_PATH=""` (empty-string) produced a
438/// cryptic `license I/O error: No such file or directory` on `health
439/// --runtime-coverage` because `read_jwt_file(Path::new(""))` fails at the
440/// fs layer.
441fn resolve_license_path_env(raw: Option<String>) -> Option<PathBuf> {
442    let raw = raw?;
443    let trimmed = raw.trim();
444    if trimmed.is_empty() {
445        None
446    } else {
447        Some(PathBuf::from(trimmed))
448    }
449}
450
451fn read_jwt_file(path: &Path) -> Result<String, LicenseError> {
452    let raw = std::fs::read_to_string(path)?;
453    Ok(normalize_jwt(&raw))
454}
455
456/// Resolve the user's home directory in a cross-platform way.
457///
458/// Checks `$HOME` first (standard on Unix and set by Git Bash / MSYS /
459/// Cygwin on Windows), then `%USERPROFILE%` (native Windows). Returns
460/// `None` only when neither resolves to a non-empty string, which in
461/// practice means a bare container with no home set — callers decide
462/// whether to fall back to cwd or error.
463#[must_use]
464pub fn user_home_dir() -> Option<PathBuf> {
465    user_home_from_env(|key| std::env::var(key).ok())
466}
467
468fn user_home_from_env(getenv: impl Fn(&str) -> Option<String>) -> Option<PathBuf> {
469    for key in ["HOME", "USERPROFILE"] {
470        if let Some(value) = getenv(key)
471            && !value.is_empty()
472        {
473            return Some(PathBuf::from(value));
474        }
475    }
476    None
477}
478
479/// Compute the canonical default license path (`~/.fallow/license.jwt`).
480///
481/// On Unix this reads `$HOME`; on Windows it falls back to `%USERPROFILE%`
482/// when `$HOME` is not set (native cmd / PowerShell). Falls back to
483/// `./.fallow/license.jwt` if neither resolves — exotic containers and
484/// CI sandboxes being the usual suspects.
485#[must_use]
486pub fn default_license_path() -> PathBuf {
487    user_home_dir()
488        .unwrap_or_else(|| PathBuf::from("."))
489        .join(".fallow")
490        .join("license.jwt")
491}
492
493/// Strip whitespace and embedded line breaks from a pasted JWT.
494///
495/// Shells routinely fold long tokens onto multiple lines, especially via
496/// PowerShell or zsh's bracketed-paste. This is the single normalization
497/// hook used by every input path (env var, file, CLI arg, stdin).
498#[must_use]
499pub fn normalize_jwt(raw: &str) -> String {
500    raw.chars()
501        .filter(|c| !c.is_whitespace())
502        .collect::<String>()
503}
504
505/// Wrapper around `SystemTime::now()` returning unix seconds.
506///
507/// Returns `0` if the system clock is before the unix epoch (impossible in
508/// practice — included to avoid `unwrap`).
509#[must_use]
510pub fn current_unix_seconds() -> i64 {
511    SystemTime::now()
512        .duration_since(UNIX_EPOCH)
513        .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
514}
515
516const SECONDS_PER_DAY: i64 = 86_400;
517
518/// Resolve the clock-skew tolerance (in seconds) from
519/// `FALLOW_LICENSE_SKEW_TOLERANCE_SECONDS`, falling back to
520/// [`DEFAULT_SKEW_TOLERANCE_SECONDS`] when the variable is unset, empty,
521/// whitespace-only, or unparsable.
522///
523/// Parsing is lenient by design: a typo in a CI runner's env block must not
524/// fail license verification. The value is parsed as `u64` and capped at
525/// `i64::MAX`, so any positive integer is accepted.
526#[must_use]
527pub fn skew_tolerance_seconds_from_env() -> i64 {
528    skew_tolerance_seconds_from(|key| std::env::var(key).ok())
529}
530
531fn skew_tolerance_seconds_from(getenv: impl Fn(&str) -> Option<String>) -> i64 {
532    let Some(raw) = getenv(SKEW_TOLERANCE_ENV) else {
533        return DEFAULT_SKEW_TOLERANCE_SECONDS;
534    };
535    let trimmed = raw.trim();
536    if trimmed.is_empty() {
537        return DEFAULT_SKEW_TOLERANCE_SECONDS;
538    }
539    match trimmed.parse::<u64>() {
540        Ok(value) => i64::try_from(value).unwrap_or(i64::MAX),
541        Err(_) => DEFAULT_SKEW_TOLERANCE_SECONDS,
542    }
543}
544
545/// Render a duration in seconds as a human-friendly string. Used by
546/// [`LicenseError::ClockSkew`]'s [`Display`] impl so users see "2 days"
547/// instead of "172800 seconds".
548///
549/// Integer floor at each tier; no fractional units. Tiers:
550/// `< 60s` -> "N seconds", `< 3600s` -> "M minutes", `< 86_400s` ->
551/// "H hours [M minutes]", `>= 86_400s` -> "D days [H hours]".
552///
553/// [`Display`]: std::fmt::Display
554fn format_duration_seconds(seconds: u64) -> String {
555    const MINUTE: u64 = 60;
556    const HOUR: u64 = 60 * MINUTE;
557    const DAY: u64 = 24 * HOUR;
558
559    fn unit(value: u64, singular: &str) -> String {
560        if value == 1 {
561            format!("1 {singular}")
562        } else {
563            format!("{value} {singular}s")
564        }
565    }
566
567    if seconds < MINUTE {
568        return unit(seconds, "second");
569    }
570    if seconds < HOUR {
571        return unit(seconds / MINUTE, "minute");
572    }
573    if seconds < DAY {
574        let hours = seconds / HOUR;
575        let minutes = (seconds % HOUR) / MINUTE;
576        if minutes == 0 {
577            return unit(hours, "hour");
578        }
579        return format!("{} {}", unit(hours, "hour"), unit(minutes, "minute"));
580    }
581    let days = seconds / DAY;
582    let hours = (seconds % DAY) / HOUR;
583    if hours == 0 {
584        return unit(days, "day");
585    }
586    format!("{} {}", unit(days, "day"), unit(hours, "hour"))
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    use ed25519_dalek::{Signer, SigningKey};
594    use rand::rngs::OsRng;
595
596    fn fixed_keypair() -> (SigningKey, VerifyingKey) {
597        let mut csprng = OsRng;
598        let signing = SigningKey::generate(&mut csprng);
599        let verifying = signing.verifying_key();
600        (signing, verifying)
601    }
602
603    fn sign_jwt(signing: &SigningKey, claims: &LicenseClaims) -> String {
604        let header = serde_json::json!({"alg": "EdDSA", "typ": "JWT"});
605        let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
606        let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap());
607        let signing_input = format!("{header_b64}.{payload_b64}");
608        let signature = signing.sign(signing_input.as_bytes());
609        let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
610        format!("{header_b64}.{payload_b64}.{sig_b64}")
611    }
612
613    fn make_claims(exp: i64) -> LicenseClaims {
614        LicenseClaims {
615            iss: "https://api.fallow.cloud".into(),
616            sub: "org_test".into(),
617            tid: "tenant_test".into(),
618            seats: 5,
619            tier: "pro".into(),
620            features: vec!["runtime_coverage".into()],
621            iat: 1_700_000_000,
622            exp,
623            jti: "jti_test".into(),
624            refresh_after: Some(1_700_000_000 + 15 * SECONDS_PER_DAY),
625        }
626    }
627
628    #[test]
629    fn valid_jwt_passes_verification() {
630        let (signing, verifying) = fixed_keypair();
631        let claims = make_claims(2_000_000_000);
632        let jwt = sign_jwt(&signing, &claims);
633        let status = verify_jwt(&jwt, &verifying, 1_900_000_000, DEFAULT_HARD_FAIL_DAYS).unwrap();
634        assert!(matches!(status, LicenseStatus::Valid { .. }));
635        assert!(status.permits(&Feature::RuntimeCoverage));
636        assert!(!status.permits(&Feature::PortfolioDashboard));
637    }
638
639    #[test]
640    fn tampered_payload_fails_signature() {
641        let (signing, verifying) = fixed_keypair();
642        let claims = make_claims(2_000_000_000);
643        let mut jwt = sign_jwt(&signing, &claims);
644        let mid = jwt.find('.').unwrap() + 5;
645        let bad: String = jwt
646            .chars()
647            .enumerate()
648            .map(|(i, c)| if i == mid { 'X' } else { c })
649            .collect();
650        jwt = bad;
651        let err = verify_jwt(&jwt, &verifying, 1_900_000_000, DEFAULT_HARD_FAIL_DAYS).unwrap_err();
652        assert!(matches!(
653            err,
654            LicenseError::BadSignature | LicenseError::BadPayload(_)
655        ));
656    }
657
658    #[test]
659    fn rs256_header_rejected() {
660        let (signing, verifying) = fixed_keypair();
661        let header = serde_json::json!({"alg": "RS256", "typ": "JWT"});
662        let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
663        let claims = make_claims(2_000_000_000);
664        let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap());
665        let signing_input = format!("{header_b64}.{payload_b64}");
666        let signature = signing.sign(signing_input.as_bytes());
667        let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
668        let jwt = format!("{header_b64}.{payload_b64}.{sig_b64}");
669        let err = verify_jwt(&jwt, &verifying, 1_900_000_000, DEFAULT_HARD_FAIL_DAYS).unwrap_err();
670        assert!(matches!(err, LicenseError::BadHeader(_)));
671    }
672
673    #[test]
674    fn alg_none_rejected() {
675        let (_, verifying) = fixed_keypair();
676        let header = serde_json::json!({"alg": "none", "typ": "JWT"});
677        let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
678        let claims = make_claims(2_000_000_000);
679        let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap());
680        let jwt = format!("{header_b64}.{payload_b64}.");
681        let err = verify_jwt(&jwt, &verifying, 1_900_000_000, DEFAULT_HARD_FAIL_DAYS).unwrap_err();
682        assert!(matches!(err, LicenseError::BadHeader(_)));
683    }
684
685    #[test]
686    fn truncated_token_returns_specific_error() {
687        let (_, verifying) = fixed_keypair();
688        let err = verify_jwt("eyJh.short", &verifying, 0, DEFAULT_HARD_FAIL_DAYS).unwrap_err();
689        assert!(matches!(err, LicenseError::Truncated { .. }));
690    }
691
692    #[test]
693    fn whitespace_in_jwt_normalized() {
694        let raw = "eyJ\n  abcd\r\nef.gh\nij.kl  mn";
695        assert_eq!(normalize_jwt(raw), "eyJabcdef.ghij.klmn");
696    }
697
698    #[test]
699    fn normalize_jwt_empty_string_stays_empty() {
700        assert!(normalize_jwt("").is_empty());
701    }
702
703    #[test]
704    fn normalize_jwt_whitespace_only_becomes_empty() {
705        assert!(normalize_jwt("   ").is_empty());
706        assert!(normalize_jwt("\t\n\r ").is_empty());
707    }
708
709    #[test]
710    fn grace_ladder_classifies_correctly() {
711        let claims = make_claims(1_000_000_000);
712        assert!(matches!(
713            grace_state(claims.clone(), 1_000_000_000, 30),
714            LicenseStatus::Valid { .. }
715        ));
716        assert!(matches!(
717            grace_state(claims.clone(), 1_000_000_000 + 3 * SECONDS_PER_DAY, 30),
718            LicenseStatus::ExpiredWarning { .. }
719        ));
720        assert!(matches!(
721            grace_state(claims.clone(), 1_000_000_000 + 15 * SECONDS_PER_DAY, 30),
722            LicenseStatus::ExpiredWatermark { .. }
723        ));
724        assert!(matches!(
725            grace_state(claims, 1_000_000_000 + 35 * SECONDS_PER_DAY, 30),
726            LicenseStatus::HardFail { .. }
727        ));
728    }
729
730    #[test]
731    fn watermark_status_only_in_watermark_window() {
732        let claims = make_claims(1_000_000_000);
733        let valid = grace_state(claims.clone(), 1_000_000_000 - 100, 30);
734        let warn = grace_state(claims.clone(), 1_000_000_000 + 3 * SECONDS_PER_DAY, 30);
735        let watermark = grace_state(claims.clone(), 1_000_000_000 + 15 * SECONDS_PER_DAY, 30);
736        let hard = grace_state(claims, 1_000_000_000 + 60 * SECONDS_PER_DAY, 30);
737
738        assert!(!valid.show_watermark());
739        assert!(!warn.show_watermark());
740        assert!(watermark.show_watermark());
741        assert!(!hard.show_watermark());
742    }
743
744    #[test]
745    fn permits_short_circuits_on_hard_fail() {
746        let claims = make_claims(1_000_000_000);
747        let hard = grace_state(claims, 1_000_000_000 + 60 * SECONDS_PER_DAY, 30);
748        assert!(!hard.permits(&Feature::RuntimeCoverage));
749    }
750
751    #[test]
752    fn unknown_feature_round_trips_through_other() {
753        let parsed = Feature::parse("future_feature");
754        assert!(matches!(parsed, Feature::Other(ref s) if s == "future_feature"));
755    }
756
757    #[test]
758    fn refresh_after_parses_when_present_and_defaults_to_none() {
759        let with_refresh = serde_json::json!({
760            "iss": "https://api.fallow.cloud",
761            "sub": "org_test",
762            "tid": "tenant_test",
763            "seats": 5,
764            "tier": "pro",
765            "features": ["runtime_coverage"],
766            "iat": 1_700_000_000,
767            "exp": 2_000_000_000_i64,
768            "jti": "jti_test",
769            "refresh_after": 1_701_296_000_i64,
770        });
771        let claims: LicenseClaims = serde_json::from_value(with_refresh).expect("parse");
772        assert_eq!(claims.refresh_after, Some(1_701_296_000));
773
774        let without_refresh = serde_json::json!({
775            "iss": "https://api.fallow.cloud",
776            "sub": "org_test",
777            "tid": "tenant_test",
778            "seats": 5,
779            "tier": "pro",
780            "features": ["runtime_coverage"],
781            "iat": 1_700_000_000,
782            "exp": 2_000_000_000_i64,
783            "jti": "jti_test",
784        });
785        let claims: LicenseClaims = serde_json::from_value(without_refresh).expect("parse");
786        assert_eq!(claims.refresh_after, None);
787    }
788
789    #[test]
790    fn user_home_from_env_prefers_home_over_userprofile() {
791        let getenv = |key: &str| match key {
792            "HOME" => Some("/home/alice".to_owned()),
793            "USERPROFILE" => Some(r"C:\Users\alice".to_owned()),
794            _ => None,
795        };
796        assert_eq!(
797            user_home_from_env(getenv),
798            Some(PathBuf::from("/home/alice"))
799        );
800    }
801
802    #[test]
803    fn user_home_from_env_falls_back_to_userprofile_on_windows() {
804        let getenv = |key: &str| match key {
805            "USERPROFILE" => Some(r"C:\Users\alice".to_owned()),
806            _ => None,
807        };
808        assert_eq!(
809            user_home_from_env(getenv),
810            Some(PathBuf::from(r"C:\Users\alice"))
811        );
812    }
813
814    #[test]
815    fn user_home_from_env_skips_empty_values() {
816        let getenv = |key: &str| match key {
817            "HOME" => Some(String::new()),
818            "USERPROFILE" => Some(r"C:\Users\alice".to_owned()),
819            _ => None,
820        };
821        assert_eq!(
822            user_home_from_env(getenv),
823            Some(PathBuf::from(r"C:\Users\alice"))
824        );
825    }
826
827    #[test]
828    fn user_home_from_env_returns_none_when_nothing_set() {
829        assert_eq!(user_home_from_env(|_| None), None);
830    }
831
832    #[test]
833    fn resolve_license_path_env_returns_none_for_unset() {
834        assert_eq!(resolve_license_path_env(None), None);
835    }
836
837    #[test]
838    fn resolve_license_path_env_returns_none_for_empty_string() {
839        assert_eq!(resolve_license_path_env(Some(String::new())), None);
840    }
841
842    #[test]
843    fn resolve_license_path_env_returns_none_for_whitespace_only() {
844        assert_eq!(resolve_license_path_env(Some("   ".to_owned())), None);
845        assert_eq!(resolve_license_path_env(Some("\t\n".to_owned())), None);
846    }
847
848    #[test]
849    fn resolve_license_path_env_trims_surrounding_whitespace() {
850        assert_eq!(
851            resolve_license_path_env(Some("  /tmp/license.jwt  ".to_owned())),
852            Some(PathBuf::from("/tmp/license.jwt"))
853        );
854    }
855
856    #[test]
857    fn resolve_license_path_env_returns_path_for_valid_value() {
858        assert_eq!(
859            resolve_license_path_env(Some("/etc/fallow/license.jwt".to_owned())),
860            Some(PathBuf::from("/etc/fallow/license.jwt"))
861        );
862    }
863
864    fn make_claims_with_iat(iat: i64, exp: i64) -> LicenseClaims {
865        LicenseClaims {
866            iss: "https://api.fallow.cloud".into(),
867            sub: "org_test".into(),
868            tid: "tenant_test".into(),
869            seats: 5,
870            tier: "pro".into(),
871            features: vec!["runtime_coverage".into()],
872            iat,
873            exp,
874            jti: "jti_test".into(),
875            refresh_after: None,
876        }
877    }
878
879    #[test]
880    fn iat_within_tolerance_passes() {
881        let (signing, verifying) = fixed_keypair();
882        let now = 1_900_000_000;
883        let claims = make_claims_with_iat(now + 3_600, now + 100 * SECONDS_PER_DAY);
884        let jwt = sign_jwt(&signing, &claims);
885        let status = verify_jwt_with_skew(
886            &jwt,
887            &verifying,
888            now,
889            DEFAULT_HARD_FAIL_DAYS,
890            DEFAULT_SKEW_TOLERANCE_SECONDS,
891        )
892        .expect("within-tolerance JWT must verify");
893        assert!(matches!(status, LicenseStatus::Valid { .. }));
894    }
895
896    #[test]
897    fn iat_far_in_future_rejected_as_clock_skew() {
898        let (signing, verifying) = fixed_keypair();
899        let now = 1_900_000_000;
900        let claims = make_claims_with_iat(now + 48 * 3_600, now + 100 * SECONDS_PER_DAY);
901        let jwt = sign_jwt(&signing, &claims);
902        let err = verify_jwt_with_skew(
903            &jwt,
904            &verifying,
905            now,
906            DEFAULT_HARD_FAIL_DAYS,
907            DEFAULT_SKEW_TOLERANCE_SECONDS,
908        )
909        .expect_err("future-iat JWT must be rejected");
910        assert!(
911            matches!(err, LicenseError::ClockSkew { .. }),
912            "expected ClockSkew, got {err:?}"
913        );
914    }
915
916    #[test]
917    fn clock_far_behind_iat_rejected_as_clock_skew() {
918        let (signing, verifying) = fixed_keypair();
919        let iat = 1_700_000_000;
920        let now = iat - 60 * SECONDS_PER_DAY;
921        let claims = make_claims_with_iat(iat, iat + 100 * SECONDS_PER_DAY);
922        let jwt = sign_jwt(&signing, &claims);
923        let err = verify_jwt_with_skew(
924            &jwt,
925            &verifying,
926            now,
927            DEFAULT_HARD_FAIL_DAYS,
928            DEFAULT_SKEW_TOLERANCE_SECONDS,
929        )
930        .expect_err("clock-behind verification must be rejected");
931        assert!(
932            matches!(err, LicenseError::ClockSkew { .. }),
933            "expected ClockSkew, got {err:?}"
934        );
935    }
936
937    #[test]
938    fn verify_jwt_shim_uses_default_tolerance() {
939        let (signing, verifying) = fixed_keypair();
940        let now = 1_900_000_000;
941        let claims = make_claims_with_iat(now + 48 * 3_600, now + 100 * SECONDS_PER_DAY);
942        let jwt = sign_jwt(&signing, &claims);
943        let err = verify_jwt(&jwt, &verifying, now, DEFAULT_HARD_FAIL_DAYS)
944            .expect_err("shim must reject 48h-future iat under default tolerance");
945        assert!(matches!(err, LicenseError::ClockSkew { .. }));
946    }
947
948    #[test]
949    fn clock_skew_display_is_human_friendly() {
950        let err = LicenseError::ClockSkew {
951            iat_seconds: 1_900_000_000 + 2 * SECONDS_PER_DAY,
952            now_seconds: 1_900_000_000,
953            tolerance_seconds: DEFAULT_SKEW_TOLERANCE_SECONDS,
954        };
955        let rendered = format!("{err}");
956        assert!(
957            !rendered.contains("iat"),
958            "ClockSkew Display must not leak 'iat' jargon: {rendered}"
959        );
960        assert!(
961            rendered.contains("days"),
962            "ClockSkew Display must render a human-friendly duration: {rendered}"
963        );
964        assert!(
965            rendered.contains("CI") || rendered.contains("NTP") || rendered.contains("drift"),
966            "ClockSkew Display must name a non-user-error cause: {rendered}"
967        );
968        assert!(
969            rendered.contains(SKEW_TOLERANCE_ENV),
970            "ClockSkew Display must mention the env var override: {rendered}"
971        );
972    }
973
974    #[test]
975    fn skew_tolerance_seconds_from_env_parses_or_defaults() {
976        let unset = |_: &str| None;
977        assert_eq!(
978            skew_tolerance_seconds_from(unset),
979            DEFAULT_SKEW_TOLERANCE_SECONDS
980        );
981
982        let empty = |_: &str| Some(String::new());
983        assert_eq!(
984            skew_tolerance_seconds_from(empty),
985            DEFAULT_SKEW_TOLERANCE_SECONDS
986        );
987
988        let whitespace = |_: &str| Some("   \t\n".to_owned());
989        assert_eq!(
990            skew_tolerance_seconds_from(whitespace),
991            DEFAULT_SKEW_TOLERANCE_SECONDS
992        );
993
994        let garbage = |_: &str| Some("twenty".to_owned());
995        assert_eq!(
996            skew_tolerance_seconds_from(garbage),
997            DEFAULT_SKEW_TOLERANCE_SECONDS
998        );
999
1000        let negative = |_: &str| Some("-1".to_owned());
1001        assert_eq!(
1002            skew_tolerance_seconds_from(negative),
1003            DEFAULT_SKEW_TOLERANCE_SECONDS
1004        );
1005
1006        let valid = |_: &str| Some("172800".to_owned());
1007        assert_eq!(skew_tolerance_seconds_from(valid), 172_800);
1008
1009        let valid_trimmed = |_: &str| Some("  3600  ".to_owned());
1010        assert_eq!(skew_tolerance_seconds_from(valid_trimmed), 3_600);
1011
1012        let huge = |_: &str| Some(u64::MAX.to_string());
1013        assert_eq!(skew_tolerance_seconds_from(huge), i64::MAX);
1014    }
1015
1016    #[test]
1017    fn format_duration_seconds_renders_human_friendly() {
1018        assert_eq!(format_duration_seconds(0), "0 seconds");
1019        assert_eq!(format_duration_seconds(1), "1 second");
1020        assert_eq!(format_duration_seconds(45), "45 seconds");
1021        assert_eq!(format_duration_seconds(59), "59 seconds");
1022        assert_eq!(format_duration_seconds(60), "1 minute");
1023        assert_eq!(format_duration_seconds(90), "1 minute");
1024        assert_eq!(format_duration_seconds(120), "2 minutes");
1025        assert_eq!(format_duration_seconds(3_599), "59 minutes");
1026        assert_eq!(format_duration_seconds(3_600), "1 hour");
1027        assert_eq!(format_duration_seconds(3_660), "1 hour 1 minute");
1028        assert_eq!(format_duration_seconds(7_320), "2 hours 2 minutes");
1029        assert_eq!(format_duration_seconds(86_400), "1 day");
1030        assert_eq!(format_duration_seconds(90_000), "1 day 1 hour");
1031        assert_eq!(format_duration_seconds(172_800), "2 days");
1032        assert_eq!(format_duration_seconds(180_000), "2 days 2 hours");
1033    }
1034}