use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use ed25519_dalek::VerifyingKey;
use fallow_license::{
DEFAULT_HARD_FAIL_DAYS, Feature, LicenseError, LicenseStatus, current_unix_seconds,
default_license_path, normalize_jwt, verify_jwt,
};
use serde::Deserialize;
use crate::api::{NETWORK_EXIT_CODE, api_agent, api_url, http_status_message};
#[cfg(not(feature = "test-sidecar-key"))]
pub const PUBLIC_KEY_BYTES: [u8; 32] = [
179, 203, 218, 13, 98, 63, 103, 172, 91, 108, 23, 122, 27, 101, 200, 182, 174, 117, 160, 41,
167, 151, 66, 171, 13, 61, 148, 65, 181, 144, 24, 120,
];
#[cfg(feature = "test-sidecar-key")]
pub const PUBLIC_KEY_BYTES: [u8; 32] = [
0x7d, 0x59, 0xc5, 0x62, 0x3d, 0xd4, 0x0a, 0x74, 0xaa, 0x4d, 0x5a, 0x32, 0xac, 0x64, 0x5d, 0x3b,
0x3f, 0x95, 0xda, 0xea, 0xe4, 0xc2, 0x2b, 0xe2, 0x54, 0x76, 0xdd, 0x6a, 0x48, 0x6f, 0x73, 0x82,
];
#[derive(Debug)]
pub enum LicenseSubcommand {
Activate(ActivateArgs),
Status,
Refresh,
Deactivate,
}
#[derive(Debug, Clone, Default)]
pub struct ActivateArgs {
pub raw_jwt: Option<String>,
pub from_file: Option<PathBuf>,
pub from_stdin: bool,
pub trial: bool,
pub email: Option<String>,
}
#[derive(serde::Serialize)]
struct TrialRequest<'a> {
email: &'a str,
}
#[derive(Debug, Deserialize)]
struct JwtResponse {
jwt: String,
#[serde(default, rename = "trialEndsAt")]
trial_ends_at: Option<String>,
}
pub fn run(subcommand: &LicenseSubcommand) -> ExitCode {
match subcommand {
LicenseSubcommand::Activate(args) => run_activate(args),
LicenseSubcommand::Status => run_status(),
LicenseSubcommand::Refresh => run_refresh(),
LicenseSubcommand::Deactivate => run_deactivate(),
}
}
fn run_activate(args: &ActivateArgs) -> ExitCode {
if args.trial {
return run_trial(args.email.as_deref());
}
let jwt = match read_jwt(args) {
Ok(jwt) => jwt,
Err(msg) => {
eprintln!("fallow license: {msg}");
return ExitCode::from(2);
}
};
let key = match verifying_key() {
Ok(k) => k,
Err(msg) => {
eprintln!("fallow license: {msg}");
return ExitCode::from(2);
}
};
match verify_jwt(&jwt, &key, current_unix_seconds(), DEFAULT_HARD_FAIL_DAYS) {
Ok(status) => {
if let Err(msg) = persist_jwt(&jwt) {
eprintln!("fallow license: {msg}");
return ExitCode::from(2);
}
print_status(&status);
ExitCode::SUCCESS
}
Err(LicenseError::Truncated { .. }) => {
eprintln!(
"fallow license: {}",
LicenseError::Truncated { actual: jwt.len() }
);
ExitCode::from(3)
}
Err(err) => {
eprintln!("fallow license: failed to verify JWT: {err}");
ExitCode::from(3)
}
}
}
fn run_status() -> ExitCode {
let key = match verifying_key() {
Ok(k) => k,
Err(msg) => {
eprintln!("fallow license: {msg}");
return ExitCode::from(2);
}
};
match fallow_license::load_and_verify(&key, DEFAULT_HARD_FAIL_DAYS) {
Ok(status) => {
print_status(&status);
match status {
LicenseStatus::HardFail { .. } | LicenseStatus::Missing => ExitCode::from(3),
_ => ExitCode::SUCCESS,
}
}
Err(err) => {
eprintln!("fallow license: {err}");
ExitCode::from(3)
}
}
}
fn run_refresh() -> ExitCode {
match refresh_active_license() {
Ok(status) => {
print_status(&status);
ExitCode::SUCCESS
}
Err(message) => {
eprintln!("fallow license refresh: {message}");
ExitCode::from(NETWORK_EXIT_CODE)
}
}
}
fn run_trial(email: Option<&str>) -> ExitCode {
let Some(email) = email else {
eprintln!("fallow license activate --trial requires --email <addr>");
return ExitCode::from(2);
};
match activate_trial(email) {
Ok(status) => {
print_status(&status);
ExitCode::SUCCESS
}
Err(message) => {
eprintln!("fallow license activate --trial: {message}");
ExitCode::from(NETWORK_EXIT_CODE)
}
}
}
fn run_deactivate() -> ExitCode {
let path = default_license_path();
if !path.exists() {
println!("fallow license: no license file at {}", path.display());
return ExitCode::SUCCESS;
}
match std::fs::remove_file(&path) {
Ok(()) => {
println!("fallow license: removed {}", path.display());
ExitCode::SUCCESS
}
Err(err) => {
eprintln!("fallow license: failed to remove {}: {err}", path.display());
ExitCode::from(2)
}
}
}
fn read_jwt(args: &ActivateArgs) -> Result<String, String> {
if let Some(jwt) = args.raw_jwt.as_deref() {
return Ok(normalize_jwt(jwt));
}
if let Some(path) = args.from_file.as_deref() {
let raw = std::fs::read_to_string(path)
.map_err(|err| format!("failed to read {}: {err}", path.display()))?;
return Ok(normalize_jwt(&raw));
}
if args.from_stdin {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|err| format!("failed to read stdin: {err}"))?;
return Ok(normalize_jwt(&buf));
}
Err(
"no JWT provided. Pass it as a positional argument, --from-file <path>, or pipe via stdin (`-`).".to_owned(),
)
}
fn persist_jwt(jwt: &str) -> Result<(), String> {
let path = write_jwt(jwt)?;
println!("fallow license: stored at {}", path.display());
Ok(())
}
fn write_jwt(jwt: &str) -> Result<PathBuf, String> {
let path = default_license_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|err| format!("failed to create {}: {err}", parent.display()))?;
}
std::fs::write(&path, jwt)
.map_err(|err| format!("failed to write {}: {err}", path.display()))?;
restrict_license_permissions(&path)?;
Ok(path)
}
#[cfg(unix)]
fn restrict_license_permissions(path: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(path, perms)
.map_err(|err| format!("failed to set permissions on {}: {err}", path.display()))
}
#[cfg(not(unix))]
#[allow(
clippy::unnecessary_wraps,
reason = "mirrors Unix variant's Result signature for API consistency"
)]
fn restrict_license_permissions(_path: &Path) -> Result<(), String> {
Ok(())
}
pub fn verifying_key() -> Result<VerifyingKey, String> {
VerifyingKey::from_bytes(&PUBLIC_KEY_BYTES)
.map_err(|err| format!("invalid compiled-in public key: {err}"))
}
pub fn activate_trial(email: &str) -> Result<LicenseStatus, String> {
let mut response = api_agent()
.post(&api_url("/v1/auth/license/trial"))
.send_json(TrialRequest { email })
.map_err(|err| format!("failed to request a trial: {err}"))?;
if !response.status().is_success() {
return Err(http_status_message(&mut response, "trial"));
}
store_verified_jwt(&mut response, "trial")
}
pub fn refresh_active_license() -> Result<LicenseStatus, String> {
let current = load_current_jwt()?;
let mut response = api_agent()
.post(&api_url("/v1/auth/license/refresh"))
.header("Authorization", &format!("Bearer {current}"))
.send_empty()
.map_err(|err| format!("failed to refresh the current license: {err}"))?;
if !response.status().is_success() {
return Err(http_status_message(&mut response, "refresh"));
}
store_verified_jwt(&mut response, "refresh")
}
fn load_current_jwt() -> Result<String, String> {
match fallow_license::load_raw_jwt() {
Ok(Some(jwt)) => Ok(jwt),
Ok(None) => Err(
"no license found. Run: fallow license activate --trial --email you@company.com"
.to_owned(),
),
Err(err) => Err(format!("failed to read the current license: {err}")),
}
}
fn store_verified_jwt(
response: &mut impl crate::api::ResponseBodyReader,
operation: &str,
) -> Result<LicenseStatus, String> {
let payload: JwtResponse = response
.read_json()
.map_err(|err| format!("failed to parse {operation} response: {err}"))?;
let jwt = normalize_jwt(&payload.jwt);
let status = verify_downloaded_jwt(&jwt)?;
let path = write_jwt(&jwt)?;
println!("fallow license: stored at {}", path.display());
if let Some(trial_ends_at) = payload.trial_ends_at.as_deref() {
let trimmed = trial_ends_at.trim();
if !trimmed.is_empty() {
println!("fallow license: trial ends at {trimmed}");
}
}
Ok(status)
}
fn verify_downloaded_jwt(jwt: &str) -> Result<LicenseStatus, String> {
let key = verifying_key()?;
match verify_jwt(jwt, &key, current_unix_seconds(), DEFAULT_HARD_FAIL_DAYS) {
Ok(status) => Ok(status),
Err(LicenseError::Truncated { .. }) => {
Err(format!("{}", LicenseError::Truncated { actual: jwt.len() }))
}
Err(err) => Err(format!("failed to verify JWT: {err}")),
}
}
fn print_status(status: &LicenseStatus) {
match status {
LicenseStatus::Valid {
claims,
days_until_expiry,
} => {
println!(
"license: VALID, tier={} seats={} features={} days_until_expiry={}",
claims.tier,
claims.seats,
claims.features.join(","),
days_until_expiry
);
if let Some(refresh_after) = claims.refresh_after
&& current_unix_seconds() >= refresh_after
{
println!(
" refresh suggested now: fallow license refresh (prevents CI breakage before expiry)"
);
}
}
LicenseStatus::ExpiredWarning {
claims,
days_since_expiry,
} => {
println!(
"license: EXPIRED ({days_since_expiry} days ago), analysis still runs in the warning window. \
Refresh: fallow license refresh"
);
println!(
" tier={} seats={} features={}",
claims.tier,
claims.seats,
claims.features.join(",")
);
}
LicenseStatus::ExpiredWatermark {
claims,
days_since_expiry,
} => {
println!(
"license: EXPIRED ({days_since_expiry} days ago), output will show a watermark until refreshed. \
Refresh: fallow license refresh"
);
println!(
" tier={} seats={} features={}",
claims.tier,
claims.seats,
claims.features.join(",")
);
}
LicenseStatus::HardFail {
days_since_expiry, ..
} => {
println!(
"license: EXPIRED ({days_since_expiry} days ago, past grace window), paid features blocked. \
Refresh: fallow license refresh, or fallow license activate --trial --email <addr>"
);
}
LicenseStatus::Missing => {
println!(
"license: NOT FOUND. Start a 30-day trial: fallow license activate --trial --email you@company.com"
);
}
}
if status.permits(&Feature::ProductionCoverage) {
println!(" → production_coverage: ENABLED");
} else {
println!(" → production_coverage: disabled (upgrade or refresh)");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_jwt_prefers_raw_arg() {
let args = ActivateArgs {
raw_jwt: Some("a.b.c".into()),
..Default::default()
};
assert_eq!(read_jwt(&args).unwrap(), "a.b.c");
}
#[test]
fn read_jwt_normalizes_whitespace() {
let args = ActivateArgs {
raw_jwt: Some("a .b\nc".into()),
..Default::default()
};
assert_eq!(read_jwt(&args).unwrap(), "a.bc");
}
#[test]
fn read_jwt_errors_when_no_source() {
let args = ActivateArgs::default();
assert!(read_jwt(&args).is_err());
}
#[test]
fn run_trial_without_email_errors() {
let exit = run_trial(None);
assert_eq!(format!("{exit:?}"), format!("{:?}", ExitCode::from(2)));
}
}