Merge pull request #12 from 0xrsydn/fix/resilient-cache

fix(cache): treat corrupted cache entries as cache misses
This commit is contained in:
Rasyidan A F 2026-03-17 09:03:15 +07:00 committed by GitHub
commit db4ff4f9fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 97 additions and 5 deletions

View file

@ -53,7 +53,7 @@ impl Cache {
}; };
let age = Utc::now().signed_duration_since(entry.fetched_at); let age = Utc::now().signed_duration_since(entry.fetched_at);
if age if age
> chrono::Duration::from_std(Duration::from_secs(entry.ttl_secs)) >= chrono::Duration::from_std(Duration::from_secs(entry.ttl_secs))
.map_err(|e| IdxError::CacheMiss(e.to_string()))? .map_err(|e| IdxError::CacheMiss(e.to_string()))?
{ {
return Ok(None); return Ok(None);
@ -163,9 +163,26 @@ impl Cache {
if !path.exists() { if !path.exists() {
return Ok(None); return Ok(None);
} }
let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?; let raw = match fs::read_to_string(&path) {
let entry: CacheEntry<T> = Ok(s) => s,
serde_json::from_str(&raw).map_err(|e| IdxError::ParseError(e.to_string()))?; Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
return Ok(None);
}
return Err(IdxError::Io(e.to_string()));
}
};
let entry: CacheEntry<T> = match serde_json::from_str(&raw) {
Ok(e) => e,
Err(e) => {
eprintln!(
"warning: corrupted cache entry for {}/{}, treating as miss: {}",
data_type, symbol, e
);
let _ = fs::remove_file(&path);
return Ok(None);
}
};
if entry.schema_version != CURRENT_SCHEMA_VERSION { if entry.schema_version != CURRENT_SCHEMA_VERSION {
eprintln!( eprintln!(
"debug: cache schema mismatch for {} (got {}, expected {})", "debug: cache schema mismatch for {} (got {}, expected {})",
@ -185,6 +202,14 @@ impl Cache {
} }
pub fn cache_dir() -> Result<PathBuf, IdxError> { pub fn cache_dir() -> Result<PathBuf, IdxError> {
if let Ok(dir) = std::env::var("XDG_CACHE_HOME")
&& !dir.is_empty()
{
let path = PathBuf::from(dir);
if path.is_absolute() {
return Ok(path.join("idx"));
}
}
ProjectDirs::from("", "", "idx") ProjectDirs::from("", "", "idx")
.map(|d| d.cache_dir().to_path_buf()) .map(|d| d.cache_dir().to_path_buf())
.ok_or_else(|| IdxError::ConfigError("unable to resolve cache dir".to_string())) .ok_or_else(|| IdxError::ConfigError("unable to resolve cache dir".to_string()))
@ -193,18 +218,23 @@ pub fn cache_dir() -> Result<PathBuf, IdxError> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::fs; use std::fs;
use std::sync::atomic::{AtomicUsize, Ordering};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::{Cache, CacheEntry}; use super::{Cache, CacheEntry};
static TMP_COUNTER: AtomicUsize = AtomicUsize::new(0);
#[derive(Debug, Serialize, Deserialize, PartialEq)] #[derive(Debug, Serialize, Deserialize, PartialEq)]
struct T { struct T {
v: i32, v: i32,
} }
fn tmp() -> std::path::PathBuf { fn tmp() -> std::path::PathBuf {
let p = std::env::temp_dir().join(format!("idx-cache-test-{}", std::process::id())); let suffix = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let p =
std::env::temp_dir().join(format!("idx-cache-test-{}-{suffix}", std::process::id()));
let _ = fs::remove_dir_all(&p); let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).expect("create tmp cache dir"); fs::create_dir_all(&p).expect("create tmp cache dir");
p p
@ -240,4 +270,58 @@ mod tests {
.expect("cache read stale"); .expect("cache read stale");
assert_eq!(stale, Some(T { v: 7 })); assert_eq!(stale, Some(T { v: 7 }));
} }
#[test]
fn corrupted_cache_entry_returns_none_and_deletes_file() {
let root = tmp();
let cache = Cache::with_root(root.clone());
// Write invalid JSON to a cache file
let path = root.join("quote/CORRUPT.JK.json");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent dir");
}
fs::write(&path, "this is not valid json {{{").expect("write corrupted cache");
assert!(path.exists(), "corrupted file should exist before get()");
// get() should return Ok(None), not an error
let result: Option<T> = cache
.get("quote", "CORRUPT.JK")
.expect("get should not error");
assert_eq!(result, None, "corrupted entry should be treated as miss");
// The corrupted file should be deleted
assert!(!path.exists(), "corrupted file should be deleted");
}
#[test]
fn corrupted_cache_entry_get_stale_returns_none_and_deletes_file() {
let root = tmp();
let cache = Cache::with_root(root.clone());
// Write invalid JSON to a cache file
let path = root.join("quote/STALE_CORRUPT.JK.json");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent dir");
}
fs::write(&path, "{ not valid json at all").expect("write corrupted cache");
assert!(
path.exists(),
"corrupted file should exist before get_stale()"
);
// get_stale() should return Ok(None), not an error
let result: Option<T> = cache
.get_stale("quote", "STALE_CORRUPT.JK")
.expect("get_stale should not error");
assert_eq!(
result, None,
"corrupted entry should be treated as miss in get_stale"
);
// The corrupted file should be deleted
assert!(!path.exists(), "corrupted file should be deleted");
}
} }

View file

@ -197,6 +197,14 @@ pub fn default_config_toml() -> String {
} }
pub fn config_path() -> Result<PathBuf, IdxError> { pub fn config_path() -> Result<PathBuf, IdxError> {
if let Ok(dir) = std::env::var("XDG_CONFIG_HOME")
&& !dir.is_empty()
{
let path = PathBuf::from(dir);
if path.is_absolute() {
return Ok(path.join("idx").join("config.toml"));
}
}
ProjectDirs::from("", "", "idx") ProjectDirs::from("", "", "idx")
.map(|d| d.config_dir().join("config.toml")) .map(|d| d.config_dir().join("config.toml"))
.ok_or_else(|| IdxError::ConfigError("unable to resolve config dir".to_string())) .ok_or_else(|| IdxError::ConfigError("unable to resolve config dir".to_string()))