1#![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
44pub const DEFAULT_HARD_FAIL_DAYS: u64 = 30;
48
49pub const WATERMARK_DAYS: u64 = 7;
51
52pub const DEFAULT_SKEW_TOLERANCE_SECONDS: i64 = 86_400;
61
62pub const SKEW_TOLERANCE_ENV: &str = "FALLOW_LICENSE_SKEW_TOLERANCE_SECONDS";
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct LicenseClaims {
68 pub iss: String,
70 pub sub: String,
72 pub tid: String,
74 pub seats: u32,
76 pub tier: String,
81 pub features: Vec<String>,
84 pub iat: i64,
86 pub exp: i64,
88 pub jti: String,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub refresh_after: Option<i64>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Hash)]
103pub enum Feature {
104 RuntimeCoverage,
106 PortfolioDashboard,
109 McpCloudTools,
112 CrossRepoAggregation,
115 Other(String),
117}
118
119impl Feature {
120 #[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 #[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#[derive(Debug, Clone)]
145pub enum LicenseStatus {
146 Valid {
148 claims: LicenseClaims,
149 days_until_expiry: i64,
150 },
151 ExpiredWarning {
154 claims: LicenseClaims,
155 days_since_expiry: u64,
156 },
157 ExpiredWatermark {
161 claims: LicenseClaims,
162 days_since_expiry: u64,
163 },
164 HardFail {
166 claims: LicenseClaims,
167 days_since_expiry: u64,
168 },
169 Missing,
171}
172
173impl LicenseStatus {
174 #[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 #[must_use]
188 pub const fn show_watermark(&self) -> bool {
189 matches!(self, Self::ExpiredWatermark { .. })
190 }
191}
192
193#[derive(Debug)]
196pub enum LicenseError {
197 Io(std::io::Error),
199 MalformedJwt(String),
201 BadHeader(String),
203 BadPayload(String),
205 BadSignature,
207 Truncated { actual: usize },
209 ClockSkew {
218 iat_seconds: i64,
220 now_seconds: i64,
222 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
266pub 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
289pub 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#[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
397pub 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
414pub 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
434fn 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#[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#[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#[must_use]
499pub fn normalize_jwt(raw: &str) -> String {
500 raw.chars()
501 .filter(|c| !c.is_whitespace())
502 .collect::<String>()
503}
504
505#[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#[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
545fn 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}