use crate::cache::MetadataCache;
use crate::error::{KopiError, Result};
use crate::locking::LockTimeoutValue;
use crate::platform;
use std::cmp::min;
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::Path;
use std::thread;
use std::time::{Duration, Instant};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
const INITIAL_RENAME_BACKOFF: Duration = Duration::from_millis(50);
const MAX_RENAME_BACKOFF: Duration = Duration::from_millis(1_000);
const CACHE_TEMP_EXTENSION: &str = "tmp";
pub fn load_cache(path: &Path) -> Result<MetadataCache> {
let contents = fs::read_to_string(path)
.map_err(|e| KopiError::ConfigError(format!("Failed to read cache file: {e}")))?;
let cache: MetadataCache =
serde_json::from_str(&contents).map_err(|_e| KopiError::InvalidMetadata)?;
Ok(cache)
}
pub fn save_cache(
cache: &MetadataCache,
path: &Path,
timeout_budget: LockTimeoutValue,
) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
KopiError::ConfigError(format!("Failed to create cache directory: {e}"))
})?;
}
let json = serde_json::to_vec_pretty(cache).map_err(|_e| KopiError::InvalidMetadata)?;
let temp_path = path.with_extension(CACHE_TEMP_EXTENSION);
if temp_path.exists() {
fs::remove_file(&temp_path).map_err(|e| {
KopiError::ConfigError(format!(
"Failed to remove stale cache temp file '{}': {e}",
temp_path.display()
))
})?;
}
let mut options = OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
options.mode(0o600);
}
let mut temp_file = options.open(&temp_path).map_err(|e| {
KopiError::ConfigError(format!(
"Failed to create cache temp file '{}': {e}",
temp_path.display()
))
})?;
#[cfg(not(unix))]
{
let metadata = temp_file.metadata().map_err(|e| {
KopiError::ConfigError(format!("Failed to inspect cache temp file metadata: {e}"))
})?;
let mut permissions = metadata.permissions();
#[allow(clippy::permissions_set_readonly_false)]
permissions.set_readonly(false);
fs::set_permissions(&temp_path, permissions).map_err(|e| {
KopiError::ConfigError(format!(
"Failed to set permissions for cache temp file '{}': {e}",
temp_path.display()
))
})?;
}
temp_file.write_all(&json).map_err(|e| {
KopiError::ConfigError(format!(
"Failed to write cache temp file '{}': {e}",
temp_path.display()
))
})?;
temp_file.sync_all().map_err(|e| {
KopiError::ConfigError(format!(
"Failed to flush cache temp file '{}': {e}",
temp_path.display()
))
})?;
drop(temp_file);
rename_with_retry(
|| platform::file_ops::atomic_rename(&temp_path, path),
timeout_budget,
)
.map_err(|failure| {
let timeout_hint = if timeout_budget.is_infinite() {
"Configured lock timeout is infinite; ensure no other process holds the cache file open."
.to_string()
} else {
let waited = format_duration(failure.elapsed);
format!(
"Waited {waited} while promoting the cache file. Increase the locking timeout or retry after other Kopi processes finish."
)
};
KopiError::ConfigError(format!(
"Failed to finalise cache write after {} attempt(s): {}. {timeout_hint}",
failure.attempts.max(1),
failure.error
))
})?;
Ok(())
}
struct RenameRetryFailure {
error: io::Error,
attempts: usize,
elapsed: Duration,
}
impl RenameRetryFailure {
fn new(error: io::Error, attempts: usize, elapsed: Duration) -> Self {
Self {
error,
attempts,
elapsed,
}
}
}
fn rename_with_retry<F>(
mut rename_fn: F,
timeout_budget: LockTimeoutValue,
) -> std::result::Result<(), RenameRetryFailure>
where
F: FnMut() -> io::Result<()>,
{
let start = Instant::now();
let deadline = match timeout_budget {
LockTimeoutValue::Finite(duration) => Some(start + duration),
LockTimeoutValue::Infinite => None,
};
let mut attempts = 0usize;
let mut backoff = INITIAL_RENAME_BACKOFF;
loop {
match rename_fn() {
Ok(_) => return Ok(()),
Err(err) => {
if !should_retry(&err) {
return Err(RenameRetryFailure::new(err, attempts, start.elapsed()));
}
attempts += 1;
let now = Instant::now();
if let Some(deadline) = deadline {
if now >= deadline {
return Err(RenameRetryFailure::new(err, attempts, start.elapsed()));
}
let remaining = deadline.saturating_duration_since(now);
if remaining.is_zero() {
return Err(RenameRetryFailure::new(err, attempts, start.elapsed()));
}
thread::sleep(min(backoff, remaining));
} else {
thread::sleep(backoff);
}
backoff = min(backoff.saturating_mul(2), MAX_RENAME_BACKOFF);
}
}
}
}
fn should_retry(err: &io::Error) -> bool {
matches!(
err.raw_os_error(),
Some(code) if code == ERROR_SHARING_VIOLATION || code == ERROR_LOCK_VIOLATION
)
}
fn format_duration(duration: Duration) -> String {
if duration.as_secs() >= 1 {
format!("{:.1}s", duration.as_secs_f32())
} else {
format!("{}ms", duration.as_millis())
}
}
const ERROR_SHARING_VIOLATION: i32 = 32;
const ERROR_LOCK_VIOLATION: i32 = 33;
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::DistributionCache;
use crate::models::distribution::Distribution as JdkDistribution;
use tempfile::TempDir;
#[test]
fn test_load_nonexistent_cache() {
let temp_dir = TempDir::new().unwrap();
let cache_path = temp_dir.path().join("cache.json");
assert!(load_cache(&cache_path).is_err());
}
#[test]
fn test_save_and_load_cache() {
let temp_dir = TempDir::new().unwrap();
let cache_path = temp_dir.path().join("cache.json");
let mut cache = MetadataCache::new();
let dist = DistributionCache {
distribution: JdkDistribution::Temurin,
display_name: "Eclipse Temurin".to_string(),
packages: Vec::new(),
};
cache.distributions.insert("temurin".to_string(), dist);
save_cache(&cache, &cache_path, LockTimeoutValue::from_secs(2)).unwrap();
let loaded_cache = load_cache(&cache_path).unwrap();
assert_eq!(loaded_cache.version, cache.version);
assert_eq!(loaded_cache.distributions.len(), 1);
assert!(loaded_cache.distributions.contains_key("temurin"));
assert!(
!cache_path.with_extension(CACHE_TEMP_EXTENSION).exists(),
"temporary cache file should be removed after successful rename"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&cache_path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "cache file should be owner read/write");
}
let stale_temp = cache_path.with_extension(CACHE_TEMP_EXTENSION);
fs::write(&stale_temp, b"partial").unwrap();
save_cache(&cache, &cache_path, LockTimeoutValue::from_secs(2)).unwrap();
assert!(
!stale_temp.exists(),
"stale cache temp file should be removed before rewriting"
);
}
#[test]
fn rename_retries_on_sharing_violation() {
use std::sync::atomic::{AtomicUsize, Ordering};
let attempts = AtomicUsize::new(0);
let result = rename_with_retry(
|| {
let current = attempts.fetch_add(1, Ordering::SeqCst);
if current < 2 {
Err(io::Error::from_raw_os_error(ERROR_SHARING_VIOLATION))
} else {
Ok(())
}
},
LockTimeoutValue::from_secs(1),
);
assert!(result.is_ok());
assert_eq!(attempts.load(Ordering::SeqCst), 3);
}
#[test]
fn rename_respects_timeout_budget() {
use std::sync::atomic::{AtomicUsize, Ordering};
let attempts = AtomicUsize::new(0);
let failure = rename_with_retry(
|| {
attempts.fetch_add(1, Ordering::SeqCst);
Err(io::Error::from_raw_os_error(ERROR_SHARING_VIOLATION))
},
LockTimeoutValue::from_secs(0),
)
.expect_err("rename should time out when budget is exhausted");
assert!(failure.elapsed <= Duration::from_millis(5));
assert!(
failure.error.kind() == io::ErrorKind::PermissionDenied
|| failure.error.raw_os_error() == Some(ERROR_SHARING_VIOLATION)
);
assert!(attempts.load(Ordering::SeqCst) >= 1);
}
}