use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::fetch::FetchReport;
pub const CACHE_TTL_SECS: u64 = 120;
#[derive(Serialize, Deserialize)]
struct CachedFetch {
fetched_at: u64,
report: FetchReport,
}
pub fn cache_dir() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cache")))?;
Some(base.join("darash").join("fetch"))
}
pub fn cache_key(url: &str) -> String {
let digest = Sha256::digest(url.as_bytes());
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
hex.push_str(&format!("{byte:02x}"));
}
hex.truncate(32);
hex
}
pub fn load(url: &str) -> Option<(FetchReport, u64)> {
let dir = cache_dir()?;
let path = dir.join(format!("{}.json", cache_key(url)));
let bytes = fs::read(&path).ok()?;
let cached: CachedFetch = serde_json::from_slice(&bytes).ok()?;
let now = now_secs();
let age = now.saturating_sub(cached.fetched_at);
if age > CACHE_TTL_SECS {
let _ = fs::remove_file(&path);
return None;
}
Some((cached.report, age))
}
pub fn store(url: &str, report: &FetchReport) -> Option<()> {
let dir = cache_dir()?;
fs::create_dir_all(&dir).ok()?;
sweep(&dir);
let cached = CachedFetch {
fetched_at: now_secs(),
report: report.clone(),
};
let data = serde_json::to_vec(&cached).ok()?;
let key = cache_key(url);
let tmp = dir.join(format!(".{key}.tmp"));
write_private(&tmp, &data).ok()?;
fs::rename(&tmp, dir.join(format!("{key}.json"))).ok()?;
Some(())
}
fn write_private(path: &Path, data: &[u8]) -> std::io::Result<()> {
let mut file = fs::File::create(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
}
file.write_all(data)?;
file.sync_all()?;
Ok(())
}
fn sweep(dir: &Path) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
let now = now_secs();
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if name.ends_with(".tmp") {
let _ = fs::remove_file(&path);
continue;
}
if !name.ends_with(".json") {
continue;
}
let Ok(bytes) = fs::read(&path) else {
continue;
};
let Ok(cached) = serde_json::from_slice::<CachedFetch>(&bytes) else {
let _ = fs::remove_file(&path);
continue;
};
if now.saturating_sub(cached.fetched_at) > CACHE_TTL_SECS {
let _ = fs::remove_file(&path);
}
}
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
pub fn should_cache(
url: &str,
method: &str,
has_headers: bool,
has_body: bool,
no_store: bool,
) -> bool {
method.eq_ignore_ascii_case("GET")
&& !has_headers
&& !has_body
&& !no_store
&& !is_credential_url(url)
}
pub fn is_credential_url(url: &str) -> bool {
let Some((_, query)) = url.split_once('?') else {
return false;
};
let query = query.split('#').next().unwrap_or(query);
query
.split('&')
.filter(|pair| !pair.is_empty())
.any(|pair| is_credential_param(pair.split('=').next().unwrap_or("")))
}
pub fn is_credential_param(name: &str) -> bool {
let normalized = snake_case(name);
normalized == "api_key"
|| normalized
.split('_')
.any(|segment| CREDENTIAL_SEGMENTS.contains(&segment))
}
const CREDENTIAL_SEGMENTS: [&str; 9] = [
"token",
"key",
"secret",
"signature",
"sig",
"credential",
"password",
"authorization",
"auth",
];
fn snake_case(name: &str) -> String {
let chars: Vec<char> = name.chars().collect();
let mut out = String::new();
for (index, ch) in chars.iter().enumerate() {
if ch.is_ascii_uppercase() {
let previous = index.checked_sub(1).map(|i| chars[i]);
let next = chars.get(index + 1);
let after_lower_or_digit = previous
.map(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
.unwrap_or(false);
let acronym_end = previous.map(|c| c.is_ascii_uppercase()).unwrap_or(false)
&& next.map(|c| c.is_ascii_lowercase()).unwrap_or(false);
if (after_lower_or_digit || acronym_end) && !out.ends_with('_') {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
} else if *ch == '-' || *ch == ' ' {
out.push('_');
} else {
out.push(ch.to_ascii_lowercase());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_key_is_stable_and_url_specific() {
let key = cache_key("https://example.com/a");
assert_eq!(key.len(), 32);
assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(key, cache_key("https://example.com/a"));
assert_ne!(key, cache_key("https://example.com/b"));
}
#[test]
fn bypass_rules_cover_methods_headers_bodies_and_no_store() {
assert!(should_cache(
"https://example.com/a",
"GET",
false,
false,
false
));
assert!(!should_cache(
"https://example.com/a",
"POST",
false,
false,
false
));
assert!(!should_cache(
"https://example.com/a",
"HEAD",
false,
false,
false
));
assert!(!should_cache(
"https://example.com/a",
"GET",
true,
false,
false
));
assert!(!should_cache(
"https://example.com/a",
"GET",
false,
true,
false
));
assert!(!should_cache(
"https://example.com/a",
"GET",
false,
false,
true
));
}
#[test]
fn credential_urls_bypass_and_whole_segments_only() {
assert!(is_credential_url("https://example.com/?access_token=abc"));
assert!(is_credential_url("https://example.com/?apiKey=abc"));
assert!(is_credential_url("https://example.com/?a=1&secret=xyz"));
assert!(is_credential_url(
"https://example.com/?X-Amz-Signature=abc"
));
assert!(is_credential_url("https://example.com/?password=hunter2"));
assert!(!is_credential_url("https://example.com/?monkey=1"));
assert!(!is_credential_url("https://example.com/?q=rust"));
assert!(!is_credential_url("https://example.com/"));
assert!(!should_cache(
"https://example.com/?token=abc",
"GET",
false,
false,
false
));
}
#[test]
fn credential_param_snake_cases_camel_case_and_acronyms() {
assert!(is_credential_param("accessToken"));
assert!(is_credential_param("auth_token"));
assert!(is_credential_param("ACCESS_TOKEN"));
assert!(is_credential_param("TOKEN"));
assert!(is_credential_param("apiKey"));
assert!(!is_credential_param("monkey"));
}
}