use std::fs::{self, OpenOptions};
use std::io::{Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use fs2::FileExt;
use parking_lot::Mutex;
use crate::core::{Result, ResumaError};
use super::rate_limit::RateLimitBackend;
static ROOT: once_cell::sync::Lazy<Mutex<Option<PathBuf>>> =
once_cell::sync::Lazy::new(|| Mutex::new(None));
pub fn configure(root: impl AsRef<Path>) {
let p = root.as_ref().to_path_buf();
let _ = fs::create_dir_all(&p);
*ROOT.lock() = Some(p);
}
fn root_dir() -> PathBuf {
ROOT.lock()
.clone()
.unwrap_or_else(|| PathBuf::from(".resuma/rate-limit"))
}
fn key_path(key: &str) -> PathBuf {
let safe: String = key
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
root_dir().join(format!("{safe}.json"))
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[derive(serde::Serialize, serde::Deserialize, Default)]
struct BucketFile {
timestamps_ms: Vec<u64>,
}
pub struct DiskBackend;
impl RateLimitBackend for DiskBackend {
fn check(&self, key: &str, limit_per_minute: u32, window: Duration) -> Result<()> {
disk_check(key, limit_per_minute, window)
}
}
fn disk_check(key: &str, limit_per_minute: u32, window: Duration) -> Result<()> {
if limit_per_minute == 0 {
return Ok(());
}
let path = key_path(key);
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let mut file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&path)
.map_err(ResumaError::Io)?;
file.lock_exclusive().map_err(ResumaError::Io)?;
let result = (|| {
let window_ms = window.as_millis() as u64;
let now = now_ms();
let cutoff = now.saturating_sub(window_ms);
let mut bucket = read_bucket_file(&file);
bucket.timestamps_ms.retain(|t| *t > cutoff);
if bucket.timestamps_ms.len() as u32 >= limit_per_minute {
return Err(ResumaError::RateLimited);
}
bucket.timestamps_ms.push(now);
write_bucket_file(&mut file, &bucket)
})();
let _ = file.unlock();
result
}
fn read_bucket_file(file: &fs::File) -> BucketFile {
use std::io::{Read, Seek, SeekFrom};
let mut file = file;
let _ = file.seek(SeekFrom::Start(0));
let mut data = String::new();
if file.read_to_string(&mut data).is_ok() {
if let Ok(bucket) = serde_json::from_str(&data) {
return bucket;
}
}
BucketFile::default()
}
fn write_bucket_file(file: &mut fs::File, bucket: &BucketFile) -> Result<()> {
let data = serde_json::to_string(bucket).map_err(ResumaError::Serde)?;
file.set_len(0).map_err(ResumaError::Io)?;
file.seek(SeekFrom::Start(0)).map_err(ResumaError::Io)?;
file.write_all(data.as_bytes()).map_err(ResumaError::Io)?;
file.sync_all().map_err(ResumaError::Io)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exclusive_lock_serializes_updates() {
let dir = std::env::temp_dir().join(format!("resuma-rl-{}", std::process::id()));
configure(&dir);
assert!(disk_check("test-key", 100, Duration::from_secs(60)).is_ok());
assert!(disk_check("test-key", 100, Duration::from_secs(60)).is_ok());
let _ = fs::remove_dir_all(dir);
}
}