use std::collections::HashMap;
use std::sync::{LazyLock, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use kanade_shared::ipc::support::UnlockGrant;
const MAX_FAILURES: u32 = 5;
const FAILURE_WINDOW: Duration = Duration::from_secs(300);
const LOCKOUT: Duration = Duration::from_secs(300);
#[derive(Debug, Clone)]
struct Grant {
scope: String,
label: Option<String>,
deadline: Instant,
expires_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Default)]
struct Failures {
count: u32,
window_start: Option<Instant>,
blocked_until: Option<Instant>,
}
static GRANTS: LazyLock<Mutex<HashMap<String, Vec<Grant>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
static FAILURES: LazyLock<Mutex<HashMap<String, Failures>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
fn lock_recover<T>(m: &'static Mutex<T>) -> MutexGuard<'static, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn grant(sid: &str, scope: &str, label: Option<String>, ttl_minutes: u32) -> Vec<UnlockGrant> {
let ttl = Duration::from_secs(u64::from(ttl_minutes) * 60);
let now = Instant::now();
let mut map = lock_recover(&GRANTS);
let entry = map.entry(sid.to_string()).or_default();
entry.retain(|g| g.deadline > now && g.scope != scope);
entry.push(Grant {
scope: scope.to_string(),
label,
deadline: now + ttl,
expires_at: chrono::Utc::now()
+ chrono::Duration::from_std(ttl).unwrap_or_else(|_| chrono::Duration::minutes(15)),
});
to_wire(entry, now)
}
pub fn grants(sid: &str) -> Vec<UnlockGrant> {
let now = Instant::now();
let mut map = lock_recover(&GRANTS);
let Some(entry) = map.get_mut(sid) else {
return Vec::new();
};
entry.retain(|g| g.deadline > now);
if entry.is_empty() {
map.remove(sid);
return Vec::new();
}
to_wire(entry, now)
}
pub fn holds(sid: &str, scope: &str) -> bool {
let now = Instant::now();
let map = lock_recover(&GRANTS);
map.get(sid)
.is_some_and(|gs| gs.iter().any(|g| g.scope == scope && g.deadline > now))
}
pub fn lock(sid: &str) -> usize {
let now = Instant::now();
let mut map = lock_recover(&GRANTS);
match map.remove(sid) {
Some(gs) => gs.iter().filter(|g| g.deadline > now).count(),
None => 0,
}
}
fn to_wire(grants: &[Grant], now: Instant) -> Vec<UnlockGrant> {
grants
.iter()
.filter(|g| g.deadline > now)
.map(|g| UnlockGrant {
scope: g.scope.clone(),
label: g.label.clone(),
expires_at: g.expires_at,
})
.collect()
}
pub fn lockout_remaining(sid: &str) -> Option<Duration> {
let now = Instant::now();
let mut map = lock_recover(&FAILURES);
let f = map.get_mut(sid)?;
match f.blocked_until {
Some(until) if until > now => Some(until - now),
Some(_) => {
*f = Failures::default();
None
}
None => None,
}
}
pub fn record_failure(sid: &str) -> Option<Duration> {
let now = Instant::now();
let mut map = lock_recover(&FAILURES);
let f = map.entry(sid.to_string()).or_default();
let fresh = f
.window_start
.is_none_or(|start| now.duration_since(start) > FAILURE_WINDOW);
if fresh {
f.count = 0;
f.window_start = Some(now);
}
f.count += 1;
if f.count >= MAX_FAILURES {
f.blocked_until = Some(now + LOCKOUT);
return Some(LOCKOUT);
}
None
}
pub fn clear_failures(sid: &str) {
lock_recover(&FAILURES).remove(sid);
}
#[cfg(test)]
mod tests {
use super::*;
fn sid(tag: &str) -> String {
format!("S-1-5-21-test-{tag}")
}
#[test]
fn grant_then_holds_then_lock() {
let s = sid("basic");
assert!(!holds(&s, "support"), "starts locked");
let g = grant(&s, "support", Some("ヘルプデスク".into()), 15);
assert_eq!(g.len(), 1);
assert_eq!(g[0].scope, "support");
assert_eq!(g[0].label.as_deref(), Some("ヘルプデスク"));
assert!(holds(&s, "support"));
assert!(!holds(&s, "admin"), "a grant opens only its own scope");
assert_eq!(lock(&s), 1);
assert!(!holds(&s, "support"), "lock closes it");
assert_eq!(lock(&s), 0, "lock is idempotent");
}
#[test]
fn grants_are_per_user() {
let a = sid("user-a");
let b = sid("user-b");
grant(&a, "support", None, 15);
assert!(holds(&a, "support"));
assert!(!holds(&b, "support"));
}
#[test]
fn re_granting_a_scope_refreshes_instead_of_duplicating() {
let s = sid("refresh");
grant(&s, "support", None, 15);
let g = grant(&s, "support", None, 30);
assert_eq!(g.len(), 1, "one entry per scope: {g:?}");
}
#[test]
fn multiple_scopes_coexist() {
let s = sid("multi");
grant(&s, "support", None, 15);
let g = grant(&s, "admin", None, 15);
assert_eq!(g.len(), 2);
assert!(holds(&s, "support") && holds(&s, "admin"));
}
#[test]
fn expired_grants_stop_holding_and_are_swept() {
let s = sid("expiry");
let past = Instant::now() - Duration::from_secs(1);
lock_recover(&GRANTS).insert(
s.clone(),
vec![Grant {
scope: "support".into(),
label: None,
deadline: past,
expires_at: chrono::Utc::now(),
}],
);
assert!(!holds(&s, "support"), "expired grant must not hold");
assert!(grants(&s).is_empty(), "expired grant must not be listed");
assert!(
!lock_recover(&GRANTS).contains_key(&s),
"the empty entry is swept, not left to accumulate",
);
}
#[test]
fn expired_grant_does_not_block_a_fresh_one() {
let s = sid("expiry-regrant");
lock_recover(&GRANTS).insert(
s.clone(),
vec![Grant {
scope: "support".into(),
label: None,
deadline: Instant::now() - Duration::from_secs(1),
expires_at: chrono::Utc::now(),
}],
);
let g = grant(&s, "support", None, 15);
assert_eq!(g.len(), 1, "the lapsed entry was replaced, not kept: {g:?}");
assert!(holds(&s, "support"));
}
#[test]
fn failures_escalate_to_a_lockout() {
let s = sid("lockout");
assert!(lockout_remaining(&s).is_none(), "starts unblocked");
for i in 1..MAX_FAILURES {
assert!(
record_failure(&s).is_none(),
"attempt {i} must not lock out yet"
);
assert!(lockout_remaining(&s).is_none());
}
assert!(
record_failure(&s).is_some(),
"the budgeted attempt imposes the lockout"
);
assert!(lockout_remaining(&s).is_some(), "and it is in force");
}
#[test]
fn a_success_clears_the_failure_budget() {
let s = sid("clear");
record_failure(&s);
record_failure(&s);
clear_failures(&s);
for _ in 1..MAX_FAILURES {
assert!(record_failure(&s).is_none());
}
}
#[test]
fn a_served_lockout_expires_and_resets_the_count() {
let s = sid("served");
{
let mut map = lock_recover(&FAILURES);
map.insert(
s.clone(),
Failures {
count: MAX_FAILURES,
window_start: Some(Instant::now() - Duration::from_secs(1)),
blocked_until: Some(Instant::now() - Duration::from_secs(1)),
},
);
}
assert!(
lockout_remaining(&s).is_none(),
"a lockout in the past is served"
);
assert!(
record_failure(&s).is_none(),
"and the count restarted, so one more failure doesn't re-lock",
);
}
#[test]
fn failures_outside_the_window_do_not_accumulate() {
let s = sid("window");
{
let mut map = lock_recover(&FAILURES);
map.insert(
s.clone(),
Failures {
count: MAX_FAILURES - 1,
window_start: Some(Instant::now() - FAILURE_WINDOW - Duration::from_secs(1)),
blocked_until: None,
},
);
}
assert!(
record_failure(&s).is_none(),
"a failure long after the window restarts the count",
);
}
}