use chrono::TimeDelta; use std::env; #[derive(Clone, Debug)] pub struct Config { pub address: String, pub database_path: String, pub jwt_secret: String, pub access_ttl: TimeDelta, pub refresh_ttl: TimeDelta, pub allow_registration: bool, pub cors_origins: Vec, } impl Config { pub fn load() -> Result { let access_ttl = duration("BALANCE_ACCESS_TTL", TimeDelta::minutes(15))?; let refresh_ttl = duration("BALANCE_REFRESH_TTL", TimeDelta::days(30))?; let allow_registration = match value("BALANCE_ALLOW_REGISTRATION").as_str() { "" | "1" | "t" | "T" | "TRUE" | "true" | "True" => true, "0" | "f" | "F" | "FALSE" | "false" | "False" => false, _ => return Err("BALANCE_ALLOW_REGISTRATION must be true or false".into()), }; let jwt_secret = value("BALANCE_JWT_SECRET"); if jwt_secret.len() < 32 { return Err("BALANCE_JWT_SECRET must contain at least 32 characters".into()); } Ok(Self { address: fallback("BALANCE_ADDR", ":8080"), database_path: fallback("BALANCE_DB_PATH", "./data/balance.db"), jwt_secret, access_ttl, refresh_ttl, allow_registration, cors_origins: value("BALANCE_CORS_ORIGINS") .split(',') .map(str::trim) .filter(|part| !part.is_empty()) .map(str::to_owned) .collect(), }) } } fn value(key: &str) -> String { env::var(key).unwrap_or_default().trim().to_owned() } fn fallback(key: &str, default: &str) -> String { let value = value(key); if value.is_empty() { default.to_owned() } else { value } } fn duration(key: &str, default: TimeDelta) -> Result { let value = value(key); if value.is_empty() { return Ok(default); } parse_go_duration(&value) .ok_or_else(|| format!("{key} must be a Go duration such as 15m or 720h")) } /// Parse Go's signed, nanosecond-precision duration syntax and int64 range. pub fn parse_go_duration(input: &str) -> Option { let (negative, mut remaining) = match input.as_bytes().first()? { b'-' => (true, &input[1..]), b'+' => (false, &input[1..]), _ => (false, input), }; if remaining == "0" { return Some(TimeDelta::zero()); } if remaining.is_empty() { return None; } let mut total = 0i128; while !remaining.is_empty() { let integer_len = remaining.bytes().take_while(u8::is_ascii_digit).count(); let integer = if integer_len == 0 { 0 } else { remaining[..integer_len].parse::().ok()? }; remaining = &remaining[integer_len..]; let mut fraction = 0i128; let mut scale = 1i128; let mut fraction_len = 0; if remaining.starts_with('.') { remaining = &remaining[1..]; fraction_len = remaining.bytes().take_while(u8::is_ascii_digit).count(); // Beyond 18 decimal places no supported unit can affect nanoseconds. for digit in remaining.bytes().take(fraction_len.min(18)) { fraction = fraction * 10 + i128::from(digit - b'0'); scale *= 10; } remaining = &remaining[fraction_len..]; } if integer_len == 0 && fraction_len == 0 { return None; } let (unit_text, unit) = [ ("ns", 1i128), ("us", 1_000), ("µs", 1_000), ("μs", 1_000), ("ms", 1_000_000), ("s", 1_000_000_000), ("m", 60_000_000_000), ("h", 3_600_000_000_000), ] .into_iter() .find(|(unit, _)| remaining.starts_with(unit))?; remaining = &remaining[unit_text.len()..]; total = total .checked_add(integer.checked_mul(unit)?)? .checked_add(fraction * unit / scale)?; if total > i128::from(i64::MAX) + i128::from(negative) { return None; } } let nanos = i64::try_from(if negative { -total } else { total }).ok()?; Some(TimeDelta::nanoseconds(nanos)) } #[cfg(test)] mod tests { use super::*; #[test] fn duration_matches_go_syntax_and_range() { assert_eq!(parse_go_duration("1h30m"), Some(TimeDelta::minutes(90))); assert_eq!( parse_go_duration("-.5s"), Some(TimeDelta::milliseconds(-500)) ); assert_eq!(parse_go_duration("1µs"), Some(TimeDelta::microseconds(1))); assert_eq!(parse_go_duration("1.s"), Some(TimeDelta::seconds(1))); assert_eq!(parse_go_duration("0"), Some(TimeDelta::zero())); assert_eq!( parse_go_duration("9223372036854775807ns"), Some(TimeDelta::nanoseconds(i64::MAX)) ); assert_eq!( parse_go_duration("-9223372036854775808ns"), Some(TimeDelta::nanoseconds(i64::MIN)) ); for invalid in [ "", "1d", "1", "9223372036854775808ns", "1h 2m", ".s", "--1s", ] { assert!(parse_go_duration(invalid).is_none(), "{invalid}"); } } }