use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
const REJECTION_TTL: Duration = Duration::from_secs(10 * 60);
pub fn credential_state_path() -> std::path::PathBuf {
car_home::root_or_relative().join("parslee-credential-state.json")
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct CredentialState {
#[serde(skip_serializing_if = "Option::is_none")]
rejected_at: Option<DateTime<Utc>>,
}
#[derive(Default)]
struct Observation {
loaded: bool,
rejected_at: Option<DateTime<Utc>>,
}
fn observation_guard() -> MutexGuard<'static, Observation> {
static OBSERVATION: OnceLock<Mutex<Observation>> = OnceLock::new();
let cell = OBSERVATION.get_or_init(|| Mutex::new(Observation::default()));
cell.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn load_state(path: &std::path::Path) -> CredentialState {
std::fs::read_to_string(path)
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default()
}
fn save_state(path: &std::path::Path, state: &CredentialState) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(raw) = serde_json::to_string_pretty(state) {
let _ = std::fs::write(path, raw);
}
}
fn observation_is_live(at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
match (now - at).to_std() {
Ok(elapsed) => elapsed < REJECTION_TTL,
Err(_) => false,
}
}
pub fn note_credential_rejected() {
let now = Utc::now();
{
let mut guard = observation_guard();
guard.rejected_at = Some(now);
guard.loaded = true;
}
save_state(
&credential_state_path(),
&CredentialState {
rejected_at: Some(now),
},
);
}
pub fn credential_rejected() -> bool {
let mut guard = observation_guard();
if !guard.loaded {
guard.rejected_at = load_state(&credential_state_path()).rejected_at;
guard.loaded = true;
}
guard
.rejected_at
.is_some_and(|at| observation_is_live(at, Utc::now()))
}
pub fn clear_credential_rejected() {
{
let mut guard = observation_guard();
guard.rejected_at = None;
guard.loaded = true;
}
save_state(&credential_state_path(), &CredentialState::default());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_observation_expires_after_the_ttl() {
let at = Utc::now();
assert!(observation_is_live(at, at));
assert!(observation_is_live(
at,
at + chrono::Duration::seconds(REJECTION_TTL.as_secs() as i64 - 1)
));
assert!(!observation_is_live(
at,
at + chrono::Duration::seconds(REJECTION_TTL.as_secs() as i64)
));
}
#[test]
fn a_future_timestamp_is_dead_not_live_forever() {
let now = Utc::now();
assert!(!observation_is_live(now + chrono::Duration::hours(1), now));
}
#[test]
fn state_round_trips_through_disk() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("parslee-credential-state.json");
assert!(
load_state(&path).rejected_at.is_none(),
"absent file is empty"
);
let at = Utc::now();
save_state(
&path,
&CredentialState {
rejected_at: Some(at),
},
);
let loaded = load_state(&path).rejected_at.expect("round-trips");
assert_eq!(loaded.timestamp(), at.timestamp());
save_state(&path, &CredentialState::default());
assert!(load_state(&path).rejected_at.is_none());
let raw = std::fs::read_to_string(&path).unwrap();
assert!(
serde_json::from_str::<serde_json::Value>(&raw).is_ok(),
"cleared state must still be valid JSON, got: {raw}"
);
}
#[test]
fn a_corrupt_file_reads_as_no_observation() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("parslee-credential-state.json");
std::fs::write(&path, "{ this is not json").unwrap();
assert!(load_state(&path).rejected_at.is_none());
}
}