#![allow(dead_code, unused_imports, unused_qualifications, unreachable_patterns)]
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CredentialState {
Fresh,
RefreshWindow,
Grace,
Expired,
}
pub trait LifecyclePolicy: Send + Sync {
fn max_age_secs(&self, risk_level: u8) -> u64;
fn refresh_window_secs(&self, risk_level: u8) -> u64;
fn grace_period_secs(&self, risk_level: u8) -> u64;
fn session_timeout_secs(&self, _risk_level: u8) -> Option<u64> {
None
}
}
pub fn classify_credential(
issued_at: u64,
session_start: u64,
now: u64,
policy: &dyn LifecyclePolicy,
risk_level: u8,
) -> CredentialState {
if let Some(session_max) = policy.session_timeout_secs(risk_level) {
if now.saturating_sub(session_start) >= session_max {
return CredentialState::Expired;
}
}
let age = now.saturating_sub(issued_at);
let max_age = policy.max_age_secs(risk_level);
let refresh = policy.refresh_window_secs(risk_level);
let grace = policy.grace_period_secs(risk_level);
if age < max_age {
CredentialState::Fresh
} else if age < max_age + refresh {
CredentialState::RefreshWindow
} else if age < max_age + refresh + grace {
CredentialState::Grace
} else {
CredentialState::Expired
}
}
pub fn now_secs() -> u64 {
system_time_secs(SystemTime::now())
}
pub fn system_time_secs(time: SystemTime) -> u64 {
time.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn encode_cache_component(input: &str) -> String {
let mut output = String::with_capacity(input.len());
for c in input.chars() {
match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => output.push(c),
_ => {
for byte in c.to_string().as_bytes() {
output.push('~');
output.push_str(&format!("{byte:02X}"));
}
}
}
}
output
}
pub fn cache_file_path(cache_dir: &Path, components: &[&str], extension: &str) -> PathBuf {
let encoded: Vec<String> = components
.iter()
.map(|c| encode_cache_component(c))
.collect();
let filename = format!("{}.{}", encoded.join("-"), extension);
cache_dir.join(filename)
}
#[allow(unused_qualifications)]
pub fn validate_https_url(url: &str, field_name: &str) -> std::result::Result<(), String> {
if url.starts_with("https://") {
Ok(())
} else if url.starts_with("http://") {
Err(format!(
"{field_name} must use HTTPS (got {url}); cleartext HTTP is not allowed for credential endpoints"
))
} else {
Err(format!("{field_name} must be an HTTPS URL (got {url})"))
}
}
pub fn clear_cache_files(paths: &[PathBuf]) {
for path in paths {
if path.exists() {
if let Err(e) = std::fs::remove_file(path) {
tracing::warn!("failed to remove cache file {}: {e}", path.display());
}
}
}
}
pub fn exec_with_credential(
env_var: &str,
credential: &str,
command: &[String],
) -> std::io::Result<std::process::ExitStatus> {
if command.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"no command specified",
));
}
let mut cmd = std::process::Command::new(&command[0]);
if command.len() > 1 {
cmd.args(&command[1..]);
}
cmd.env(env_var, credential);
cmd.status()
}
pub fn exec_with_credential_owned(
env_var: &str,
mut credential: String,
command: &[String],
) -> std::io::Result<std::process::ExitStatus> {
let status = exec_with_credential(env_var, &credential, command)?;
zeroize::Zeroize::zeroize(&mut credential);
Ok(status)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
struct TestPolicy {
max_age: u64,
refresh: u64,
grace: u64,
session_timeout: Option<u64>,
}
impl TestPolicy {
fn new(max_age: u64, refresh: u64, grace: u64) -> Self {
Self {
max_age,
refresh,
grace,
session_timeout: None,
}
}
fn with_session_timeout(mut self, timeout: u64) -> Self {
self.session_timeout = Some(timeout);
self
}
}
impl LifecyclePolicy for TestPolicy {
fn max_age_secs(&self, _risk_level: u8) -> u64 {
self.max_age
}
fn refresh_window_secs(&self, _risk_level: u8) -> u64 {
self.refresh
}
fn grace_period_secs(&self, _risk_level: u8) -> u64 {
self.grace
}
fn session_timeout_secs(&self, _risk_level: u8) -> Option<u64> {
self.session_timeout
}
}
#[test]
fn fresh_within_max_age() {
let policy = TestPolicy::new(3600, 600, 300);
let now = 10_000;
let issued = now - 1800; assert_eq!(
classify_credential(issued, issued, now, &policy, 1),
CredentialState::Fresh
);
}
#[test]
fn refresh_window_after_max_age() {
let policy = TestPolicy::new(3600, 600, 300);
let now = 10_000;
let issued = now - 3900; assert_eq!(
classify_credential(issued, issued, now, &policy, 1),
CredentialState::RefreshWindow
);
}
#[test]
fn grace_after_refresh_window() {
let policy = TestPolicy::new(3600, 600, 300);
let now = 10_000;
let issued = now - 4300; assert_eq!(
classify_credential(issued, issued, now, &policy, 1),
CredentialState::Grace
);
}
#[test]
fn expired_after_grace() {
let policy = TestPolicy::new(3600, 600, 300);
let now = 10_000;
let issued = now - 5000; assert_eq!(
classify_credential(issued, issued, now, &policy, 1),
CredentialState::Expired
);
}
#[test]
fn session_timeout_overrides_fresh() {
let policy = TestPolicy::new(3600, 600, 300).with_session_timeout(7200);
let now = 10_000;
let session_start = now - 8000; let issued = now - 100; assert_eq!(
classify_credential(issued, session_start, now, &policy, 1),
CredentialState::Expired
);
}
#[test]
fn no_session_timeout_by_default() {
let policy = TestPolicy::new(3600, 600, 300);
let now = 10_000;
let session_start = 0; let issued = now - 100; assert_eq!(
classify_credential(issued, session_start, now, &policy, 1),
CredentialState::Fresh
);
}
#[test]
fn boundary_exactly_at_max_age() {
let policy = TestPolicy::new(3600, 600, 300);
let now = 10_000;
let issued = now - 3600; assert_eq!(
classify_credential(issued, issued, now, &policy, 1),
CredentialState::RefreshWindow
);
}
#[test]
fn zero_age_is_fresh() {
let policy = TestPolicy::new(3600, 600, 300);
let now = 10_000;
assert_eq!(
classify_credential(now, now, now, &policy, 1),
CredentialState::Fresh
);
}
#[test]
fn encode_cache_component_simple() {
assert_eq!(encode_cache_component("my-server"), "my-server");
assert_eq!(
encode_cache_component("prod.example.com"),
"prod.example.com"
);
}
#[test]
fn encode_cache_component_special_chars() {
assert_eq!(encode_cache_component("foo/bar"), "foo~2Fbar");
assert_eq!(encode_cache_component("a:b"), "a~3Ab");
assert_eq!(encode_cache_component("hello world"), "hello~20world");
}
#[test]
fn encode_cache_component_empty() {
assert_eq!(encode_cache_component(""), "");
}
#[test]
fn cache_file_path_single_component() {
let dir = Path::new("/tmp/cache");
let path = cache_file_path(dir, &["myserver"], "bin");
assert_eq!(path, PathBuf::from("/tmp/cache/myserver.bin"));
}
#[test]
fn cache_file_path_multiple_components() {
let dir = Path::new("/tmp/cache");
let path = cache_file_path(dir, &["server", "prod", "default"], "bin");
assert_eq!(path, PathBuf::from("/tmp/cache/server-prod-default.bin"));
}
#[test]
fn cache_file_path_encodes_special_chars() {
let dir = Path::new("/tmp/cache");
let path = cache_file_path(dir, &["my/server", "env:prod"], "cache");
assert_eq!(
path,
PathBuf::from("/tmp/cache/my~2Fserver-env~3Aprod.cache")
);
}
#[test]
fn validate_https_url_accepts_https() {
assert!(validate_https_url("https://example.com/auth", "oauth_url").is_ok());
}
#[test]
fn validate_https_url_rejects_http() {
let err = validate_https_url("http://example.com/auth", "oauth_url").unwrap_err();
assert!(err.contains("HTTPS"));
assert!(err.contains("oauth_url"));
}
#[test]
fn validate_https_url_rejects_other() {
let err = validate_https_url("ftp://example.com", "token_url").unwrap_err();
assert!(err.contains("HTTPS"));
}
#[test]
fn exec_with_credential_rejects_empty_command() {
let result = exec_with_credential("TOKEN", "secret", &[]);
assert!(result.is_err());
}
#[test]
fn now_secs_returns_nonzero() {
assert!(now_secs() > 1_000_000_000); }
#[test]
fn classify_issued_in_future_is_fresh() {
let policy = TestPolicy::new(3600, 600, 300);
let now = 10_000;
let issued = now + 100; assert_eq!(
classify_credential(issued, issued, now, &policy, 1),
CredentialState::Fresh
);
}
#[test]
fn classify_exactly_one_before_refresh_window() {
let policy = TestPolicy::new(3600, 600, 300);
let now = 10_000;
let issued = now - 3599; assert_eq!(
classify_credential(issued, issued, now, &policy, 1),
CredentialState::Fresh
);
}
#[test]
fn classify_exactly_at_refresh_window_end() {
let policy = TestPolicy::new(3600, 600, 300);
let now = 10_000;
let issued = now - (3600 + 600);
assert_eq!(
classify_credential(issued, issued, now, &policy, 1),
CredentialState::Grace
);
}
#[test]
fn classify_one_before_refresh_window_end() {
let policy = TestPolicy::new(3600, 600, 300);
let now = 10_000;
let issued = now - (3600 + 600 - 1);
assert_eq!(
classify_credential(issued, issued, now, &policy, 1),
CredentialState::RefreshWindow
);
}
#[test]
fn classify_exactly_at_grace_end() {
let policy = TestPolicy::new(3600, 600, 300);
let now = 10_000;
let issued = now - (3600 + 600 + 300);
assert_eq!(
classify_credential(issued, issued, now, &policy, 1),
CredentialState::Expired
);
}
#[test]
fn classify_one_before_grace_end() {
let policy = TestPolicy::new(3600, 600, 300);
let now = 10_000;
let issued = now - (3600 + 600 + 300 - 1);
assert_eq!(
classify_credential(issued, issued, now, &policy, 1),
CredentialState::Grace
);
}
#[test]
fn session_timeout_at_exact_boundary_is_expired() {
let policy = TestPolicy::new(3600, 600, 300).with_session_timeout(1000);
let now = 10_000;
let session_start = now - 1000;
let issued = now - 10; assert_eq!(
classify_credential(issued, session_start, now, &policy, 1),
CredentialState::Expired
);
}
#[test]
fn session_timeout_one_before_boundary_is_not_expired() {
let policy = TestPolicy::new(3600, 600, 300).with_session_timeout(1000);
let now = 10_000;
let session_start = now - 999; let issued = now - 10;
assert_eq!(
classify_credential(issued, session_start, now, &policy, 1),
CredentialState::Fresh
);
}
#[test]
fn encode_cache_component_tilde_is_encoded() {
let encoded = encode_cache_component("a~b");
assert!(!encoded.contains('~') || encoded.contains("~7E") || encoded.starts_with("a~7E"));
assert!(encoded.contains("7E") || !encoded.contains('~'));
}
#[test]
fn encode_cache_component_unicode_multi_byte() {
let encoded = encode_cache_component("café");
assert!(encoded.starts_with("caf"));
assert!(!encoded.contains('é'));
assert!(encoded.contains('~')); }
#[test]
fn validate_https_url_empty_string_is_error() {
let err = validate_https_url("", "endpoint").unwrap_err();
assert!(err.contains("HTTPS") || err.contains("endpoint"));
}
#[test]
fn validate_https_url_no_scheme() {
let err = validate_https_url("example.com/api", "url").unwrap_err();
assert!(err.contains("HTTPS"));
}
#[test]
fn validate_https_url_ftp_scheme_is_error() {
let err = validate_https_url("ftp://example.com/auth", "ftp_url").unwrap_err();
assert!(err.contains("HTTPS"));
assert!(err.contains("ftp_url"));
}
#[test]
fn cache_file_path_empty_components_list() {
let dir = Path::new("/tmp/cache");
let path = cache_file_path(dir, &[], "bin");
assert_eq!(path, PathBuf::from("/tmp/cache/.bin"));
}
#[test]
fn system_time_secs_before_epoch_returns_zero() {
let before_epoch = SystemTime::UNIX_EPOCH
.checked_sub(std::time::Duration::from_secs(1))
.unwrap();
assert_eq!(system_time_secs(before_epoch), 0);
}
}