use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[cfg(unix)]
const CACHE_DIR_MODE: u32 = 0o700;
#[cfg(unix)]
const CACHE_FILE_MODE: u32 = 0o600;
#[derive(Debug, Error)]
pub enum CacheError {
#[error("cannot resolve cache dir: {0}")]
NoHome(#[from] crate::paths::NoHomeDir),
#[error("cache IO: {0}")]
Io(#[from] std::io::Error),
#[error(
"refusing to use {path}: mode {mode:#o} grants group/other access. \
Run `chmod 0700 {path}` or remove the dir so jarvy can recreate it."
)]
InsecurePerms { path: String, mode: u32 },
}
pub fn cache_root() -> Result<PathBuf, CacheError> {
let dir = crate::paths::registry_remote_cache_dir()?;
fs::create_dir_all(&dir)?;
enforce_dir_perms(&dir)?;
Ok(dir)
}
pub fn tools_dir() -> Result<PathBuf, CacheError> {
let dir = cache_root()?.join("tools");
fs::create_dir_all(&dir)?;
enforce_dir_perms(&dir)?;
Ok(dir)
}
pub fn fresh_staging_tools_dir() -> Result<PathBuf, CacheError> {
let dir = cache_root()?.join("tools.new");
if dir.exists() {
fs::remove_dir_all(&dir)?;
}
fs::create_dir_all(&dir)?;
enforce_dir_perms(&dir)?;
Ok(dir)
}
pub fn swap_staging_into_tools_dir() -> Result<(), CacheError> {
let root = cache_root()?;
let active = root.join("tools");
let staging = root.join("tools.new");
let retired = root.join("tools.old");
if !staging.exists() {
return Err(CacheError::Io(std::io::Error::other(
"swap called without a staging dir; call fresh_staging_tools_dir first",
)));
}
if retired.exists() {
fs::remove_dir_all(&retired)?;
}
if active.exists() {
fs::rename(&active, &retired)?;
}
if let Err(e) = fs::rename(&staging, &active) {
crate::observability::telemetry_gate::emit(|| {
tracing::error!(
event = "registry.cache.swap_failed",
stage = "promote",
error = %e,
);
});
if retired.exists() {
if let Err(rollback_err) = fs::rename(&retired, &active) {
crate::observability::telemetry_gate::emit(|| {
tracing::error!(
event = "registry.cache.swap_failed",
stage = "rollback",
error = %rollback_err,
promote_error = %e,
);
});
}
}
return Err(e.into());
}
if retired.exists() {
fs::remove_dir_all(&retired)?;
}
Ok(())
}
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), CacheError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = path.with_extension({
let mut ext = path
.extension()
.map(|e| e.to_string_lossy().into_owned())
.unwrap_or_default();
ext.push_str(".tmp");
ext
});
let _ = fs::remove_file(&tmp);
let write_result = (|| -> Result<(), std::io::Error> {
let mut f = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
Ok(())
})();
if let Err(e) = write_result {
let _ = fs::remove_file(&tmp);
return Err(e.into());
}
fs::rename(&tmp, path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(CACHE_FILE_MODE))?;
}
Ok(())
}
fn enforce_dir_perms(dir: &Path) -> Result<(), CacheError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let meta = fs::metadata(dir)?;
let current = meta.permissions().mode() & 0o777;
if current != CACHE_DIR_MODE {
fs::set_permissions(dir, fs::Permissions::from_mode(CACHE_DIR_MODE))?;
let after = fs::metadata(dir)?.permissions().mode() & 0o777;
if after & 0o077 != 0 {
return Err(CacheError::InsecurePerms {
path: dir.display().to_string(),
mode: after,
});
}
}
}
#[cfg(not(unix))]
{
let _ = dir;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_atomic_writes_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("manifest.json");
write_atomic(&path, b"hello").expect("write");
let read = fs::read(&path).expect("read");
assert_eq!(read, b"hello");
}
#[test]
fn write_atomic_overwrites_existing() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("manifest.json");
write_atomic(&path, b"first").expect("first write");
write_atomic(&path, b"second").expect("second write");
let read = fs::read(&path).expect("read");
assert_eq!(read, b"second");
}
#[test]
fn write_atomic_creates_parent_dirs() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("a").join("b").join("c.toml");
write_atomic(&path, b"x").expect("write");
assert!(path.exists());
}
#[test]
fn write_atomic_leaves_no_tmp_on_success() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("foo.toml");
write_atomic(&path, b"x").expect("write");
let tmp_path = path.with_extension("toml.tmp");
assert!(!tmp_path.exists(), "stale .tmp should not survive success");
}
#[cfg(unix)]
#[test]
fn write_atomic_sets_0600_perms() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("secret.json");
write_atomic(&path, b"x").expect("write");
let mode = fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
assert_eq!(mode, CACHE_FILE_MODE);
}
}