use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
pub const DEFAULT_FP_WINDOW_SECS: i64 = 24 * 60 * 60;
pub const DEFAULT_HOURLY_CAP: usize = 10;
pub const HOUR_WINDOW_SECS: i64 = 3600;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilingDecision {
Allowed,
FingerprintRecentlyFiled {
secs_ago: i64,
window_secs: i64,
},
HourlyCapExceeded {
filed_this_hour: usize,
cap: usize,
},
}
impl FilingDecision {
pub fn is_allowed(&self) -> bool {
matches!(self, FilingDecision::Allowed)
}
pub fn block_reason(&self) -> String {
match self {
FilingDecision::Allowed => String::new(),
FilingDecision::FingerprintRecentlyFiled {
secs_ago,
window_secs,
} => {
let hours = window_secs / 3600;
format!(
"this error was already filed {secs_ago}s ago; \
minimum re-file window is {hours}h ({window_secs}s)"
)
}
FilingDecision::HourlyCapExceeded {
filed_this_hour,
cap,
} => {
format!(
"rate-limited: {filed_this_hour} issues already filed this hour \
(cap={cap}); try again later"
)
}
}
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct FingerprintStamps {
stamps: HashMap<String, i64>,
}
pub struct FingerprintStampStore {
path: PathBuf,
window_secs: i64,
}
impl FingerprintStampStore {
pub fn new(path: PathBuf, window_secs: i64) -> Self {
Self { path, window_secs }
}
pub fn default_path() -> PathBuf {
if let Some(cfg) = dirs::config_dir() {
cfg.join("trusty-mpm/bugreport-fp-stamps.json")
} else {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config/trusty-mpm/bugreport-fp-stamps.json")
}
}
fn load(&self, now_secs: i64) -> FingerprintStamps {
let raw = match std::fs::read_to_string(&self.path) {
Ok(s) => s,
Err(_) => return FingerprintStamps::default(),
};
let mut stamps: FingerprintStamps = serde_json::from_str(&raw).unwrap_or_default();
let cutoff = now_secs - self.window_secs;
stamps.stamps.retain(|_, &mut ts| ts >= cutoff);
stamps
}
fn save(&self, stamps: &FingerprintStamps) -> anyhow::Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(stamps)?;
std::fs::write(&self.path, json)?;
Ok(())
}
pub fn check(&self, fingerprint: &str, now_secs: i64) -> FilingDecision {
let stamps = self.load(now_secs);
if let Some(&last_filed) = stamps.stamps.get(fingerprint) {
let secs_ago = now_secs - last_filed;
if secs_ago < self.window_secs {
return FilingDecision::FingerprintRecentlyFiled {
secs_ago,
window_secs: self.window_secs,
};
}
}
FilingDecision::Allowed
}
pub fn record_filed(&self, fingerprint: &str, now_secs: i64) -> anyhow::Result<()> {
let mut stamps = self.load(now_secs);
stamps.stamps.insert(fingerprint.to_string(), now_secs);
self.save(&stamps)
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct HourlyFilings {
filings: Vec<i64>,
}
pub struct HourlyCap {
path: PathBuf,
cap: usize,
}
impl HourlyCap {
pub fn new(path: PathBuf, cap: usize) -> Self {
Self { path, cap }
}
pub fn default_path() -> PathBuf {
if let Some(cfg) = dirs::config_dir() {
cfg.join("trusty-mpm/bugreport-hourly.json")
} else {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config/trusty-mpm/bugreport-hourly.json")
}
}
fn load(&self, now_secs: i64) -> HourlyFilings {
let raw = match std::fs::read_to_string(&self.path) {
Ok(s) => s,
Err(_) => return HourlyFilings::default(),
};
let mut filings: HourlyFilings = serde_json::from_str(&raw).unwrap_or_default();
let cutoff = now_secs - HOUR_WINDOW_SECS;
filings.filings.retain(|&ts| ts >= cutoff);
filings
}
fn save(&self, filings: &HourlyFilings) -> anyhow::Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(filings)?;
std::fs::write(&self.path, json)?;
Ok(())
}
pub fn check(&self, now_secs: i64) -> FilingDecision {
let filings = self.load(now_secs);
let count = filings.filings.len();
if count >= self.cap {
FilingDecision::HourlyCapExceeded {
filed_this_hour: count,
cap: self.cap,
}
} else {
FilingDecision::Allowed
}
}
pub fn record_filed(&self, now_secs: i64) -> anyhow::Result<()> {
let mut filings = self.load(now_secs);
filings.filings.push(now_secs);
self.save(&filings)
}
}
pub struct RateLimitGuard {
fp_store: FingerprintStampStore,
hourly_cap: HourlyCap,
}
impl RateLimitGuard {
pub fn production() -> Self {
Self {
fp_store: FingerprintStampStore::new(
FingerprintStampStore::default_path(),
DEFAULT_FP_WINDOW_SECS,
),
hourly_cap: HourlyCap::new(HourlyCap::default_path(), DEFAULT_HOURLY_CAP),
}
}
pub fn with_config(
fp_path: PathBuf,
fp_window_secs: i64,
hourly_path: PathBuf,
hourly_cap: usize,
) -> Self {
Self {
fp_store: FingerprintStampStore::new(fp_path, fp_window_secs),
hourly_cap: HourlyCap::new(hourly_path, hourly_cap),
}
}
pub fn check(&self, fingerprint: &str, now_secs: i64) -> FilingDecision {
let fp_decision = self.fp_store.check(fingerprint, now_secs);
if !fp_decision.is_allowed() {
return fp_decision;
}
self.hourly_cap.check(now_secs)
}
pub fn record_filed(&self, fingerprint: &str, now_secs: i64) {
if let Err(e) = self.fp_store.record_filed(fingerprint, now_secs) {
tracing::warn!("failed to persist fingerprint stamp: {e}");
}
if let Err(e) = self.hourly_cap.record_filed(now_secs) {
tracing::warn!("failed to persist hourly cap: {e}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn fingerprint_stamp_blocks_within_window() {
let dir = tempdir().unwrap();
let store =
FingerprintStampStore::new(dir.path().join("stamps.json"), DEFAULT_FP_WINDOW_SECS);
let fp = "a".repeat(64);
let now = 1_700_000_000i64;
assert!(
store.check(&fp, now).is_allowed(),
"should be allowed initially"
);
store.record_filed(&fp, now).unwrap();
let decision = store.check(&fp, now + 10);
assert!(
!decision.is_allowed(),
"should be blocked within window: {decision:?}"
);
assert!(
matches!(decision, FilingDecision::FingerprintRecentlyFiled { .. }),
"expected FingerprintRecentlyFiled: {decision:?}"
);
}
#[test]
fn fingerprint_stamp_allows_after_window() {
let dir = tempdir().unwrap();
let window = 3600i64; let store = FingerprintStampStore::new(dir.path().join("stamps.json"), window);
let fp = "b".repeat(64);
let now = 1_700_000_000i64;
store.record_filed(&fp, now).unwrap();
let still_blocked = store.check(&fp, now + window - 1);
assert!(
!still_blocked.is_allowed(),
"should still be blocked: {still_blocked:?}"
);
let after = store.check(&fp, now + window + 1);
assert!(
after.is_allowed(),
"should be allowed after window: {after:?}"
);
}
#[test]
fn fingerprint_stamp_different_fps_independent() {
let dir = tempdir().unwrap();
let store =
FingerprintStampStore::new(dir.path().join("stamps.json"), DEFAULT_FP_WINDOW_SECS);
let fp1 = "c".repeat(64);
let fp2 = "d".repeat(64);
let now = 1_700_000_000i64;
store.record_filed(&fp1, now).unwrap();
assert!(
!store.check(&fp1, now + 60).is_allowed(),
"fp1 should be blocked"
);
assert!(
store.check(&fp2, now + 60).is_allowed(),
"fp2 should still be allowed"
);
}
#[test]
fn fingerprint_stamp_persists_across_load() {
let dir = tempdir().unwrap();
let path = dir.path().join("stamps.json");
let window = DEFAULT_FP_WINDOW_SECS;
let fp = "e".repeat(64);
let now = 1_700_000_000i64;
let store1 = FingerprintStampStore::new(path.clone(), window);
store1.record_filed(&fp, now).unwrap();
let store2 = FingerprintStampStore::new(path, window);
let decision = store2.check(&fp, now + 60);
assert!(
!decision.is_allowed(),
"stamp should persist across store instances: {decision:?}"
);
}
#[test]
fn hourly_cap_allows_under_limit() {
let dir = tempdir().unwrap();
let cap = HourlyCap::new(dir.path().join("hourly.json"), 3);
let now = 1_700_000_000i64;
cap.record_filed(now).unwrap();
cap.record_filed(now + 10).unwrap();
let decision = cap.check(now + 20);
assert!(
decision.is_allowed(),
"should be allowed under limit: {decision:?}"
);
}
#[test]
fn hourly_cap_blocks_when_exceeded() {
let dir = tempdir().unwrap();
let cap = HourlyCap::new(dir.path().join("hourly.json"), 2);
let now = 1_700_000_000i64;
cap.record_filed(now).unwrap();
cap.record_filed(now + 1).unwrap();
let decision = cap.check(now + 2);
assert!(
!decision.is_allowed(),
"should be blocked when cap exceeded: {decision:?}"
);
assert!(
matches!(
decision,
FilingDecision::HourlyCapExceeded {
filed_this_hour: 2,
cap: 2
}
),
"expected HourlyCapExceeded(2, 2): {decision:?}"
);
}
#[test]
fn hourly_cap_expires_old_filings() {
let dir = tempdir().unwrap();
let cap_val = 2;
let cap = HourlyCap::new(dir.path().join("hourly.json"), cap_val);
let now = 1_700_000_000i64;
cap.record_filed(now).unwrap();
cap.record_filed(now + 1).unwrap();
let later = now + HOUR_WINDOW_SECS + 5;
let decision = cap.check(later);
assert!(
decision.is_allowed(),
"old filings should expire from the rolling window: {decision:?}"
);
}
#[test]
fn hourly_cap_persists_across_load() {
let dir = tempdir().unwrap();
let path = dir.path().join("hourly.json");
let now = 1_700_000_000i64;
let cap1 = HourlyCap::new(path.clone(), 3);
cap1.record_filed(now).unwrap();
cap1.record_filed(now + 1).unwrap();
let cap2 = HourlyCap::new(path, 3);
let decision = cap2.check(now + 5);
assert!(
decision.is_allowed(),
"should be allowed (2 < cap=3): {decision:?}"
);
}
#[test]
fn decision_messages() {
let allowed = FilingDecision::Allowed;
assert!(allowed.block_reason().is_empty());
let fp_blocked = FilingDecision::FingerprintRecentlyFiled {
secs_ago: 600,
window_secs: 86400,
};
let msg = fp_blocked.block_reason();
assert!(msg.contains("already filed"), "{msg}");
assert!(msg.contains("600s"), "{msg}");
let hourly_blocked = FilingDecision::HourlyCapExceeded {
filed_this_hour: 10,
cap: 10,
};
let msg2 = hourly_blocked.block_reason();
assert!(msg2.contains("rate-limited"), "{msg2}");
assert!(msg2.contains("10"), "{msg2}");
}
#[test]
fn composite_guard_fp_blocks_before_hourly() {
let dir = tempdir().unwrap();
let guard = RateLimitGuard::with_config(
dir.path().join("fp.json"),
DEFAULT_FP_WINDOW_SECS,
dir.path().join("hourly.json"),
DEFAULT_HOURLY_CAP,
);
let fp = "f".repeat(64);
let now = 1_700_000_000i64;
assert!(guard.check(&fp, now).is_allowed());
guard.record_filed(&fp, now);
let blocked = guard.check(&fp, now + 60);
assert!(
matches!(blocked, FilingDecision::FingerprintRecentlyFiled { .. }),
"expected fingerprint block: {blocked:?}"
);
}
#[test]
fn composite_guard_hourly_blocks_when_fp_allows() {
let dir = tempdir().unwrap();
let cap = 2;
let guard = RateLimitGuard::with_config(
dir.path().join("fp.json"),
DEFAULT_FP_WINDOW_SECS,
dir.path().join("hourly.json"),
cap,
);
let now = 1_700_000_000i64;
let fp1 = "g".repeat(64);
let fp2 = "h".repeat(64);
guard.record_filed(&fp1, now);
guard.record_filed(&fp2, now + 1);
let fp3 = "i".repeat(64);
let blocked = guard.check(&fp3, now + 2);
assert!(
matches!(blocked, FilingDecision::HourlyCapExceeded { .. }),
"expected hourly cap block: {blocked:?}"
);
}
}