//! Atomic private file writes and bounded download-cache helpers //! (port of `storage/atomicFile.js` and `storage/downloadCache.js`). //! //! Improvement over 0.1.0: the temporary file is fsynced before the rename and the //! parent directory is fsynced afterwards on POSIX, so a crash cannot leave a //! truncated JSON file behind. use crate::core::error::{CoreError, CoreResult}; use rand::RngCore; use serde::Serialize; use std::fs::{self, File, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; fn random_suffix() -> String { let mut bytes = [0u8; 6]; rand::thread_rng().fill_bytes(&mut bytes); hex::encode(bytes) } #[cfg(unix)] fn open_private(path: &Path, mode: u32) -> std::io::Result { use std::os::unix::fs::OpenOptionsExt; OpenOptions::new().write(true).create_new(true).mode(mode).open(path) } #[cfg(not(unix))] fn open_private(path: &Path, _mode: u32) -> std::io::Result { OpenOptions::new().write(true).create_new(true).open(path) } pub fn ensure_private_dir(path: &Path) -> CoreResult<()> { fs::create_dir_all(path)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o700)); } Ok(()) } pub fn atomic_write_file(path: &Path, content: &[u8], mode: u32) -> CoreResult<()> { let parent = path.parent().ok_or_else(|| CoreError::new("EIO", "Target has no parent directory."))?; fs::create_dir_all(parent)?; let file_name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); let temp = parent.join(format!("{file_name}.tmp-{}", random_suffix())); let result = (|| -> CoreResult<()> { let mut file = open_private(&temp, mode)?; file.write_all(content)?; file.sync_all()?; drop(file); // `rename` replaces atomically on POSIX and uses MoveFileEx(REPLACE_EXISTING) on Windows. fs::rename(&temp, path)?; #[cfg(unix)] if let Ok(dir) = File::open(parent) { let _ = dir.sync_all(); } Ok(()) })(); if result.is_err() { let _ = fs::remove_file(&temp); } result } pub fn atomic_write_json(path: &Path, value: &T) -> CoreResult<()> { let mut text = serde_json::to_string_pretty(value)?; text.push('\n'); atomic_write_file(path, text.as_bytes(), 0o600) } pub fn read_json(path: &Path) -> CoreResult { let text = fs::read_to_string(path)?; Ok(serde_json::from_str(&text)?) } fn resolved_cache_root(root: &Path) -> CoreResult { if root.as_os_str().is_empty() { return Err(CoreError::plain("Download cache root is required.")); } let resolved = std::path::absolute(root)?; if resolved.parent().is_none() { return Err(CoreError::plain("Refusing to use a filesystem root as a download cache.")); } Ok(resolved) } pub fn clean_download_cache(root: &Path) -> CoreResult<(bool, PathBuf)> { let root = resolved_cache_root(root)?; let existed = root.exists(); if existed { fs::remove_dir_all(&root)?; } ensure_private_dir(&root)?; Ok((existed, root)) } /// True when `target` is strictly inside `root` (lexically and after symlink resolution). pub fn is_strictly_inside(root: &Path, target: &Path) -> bool { let (Ok(root), Ok(target)) = (std::path::absolute(root), std::path::absolute(target)) else { return false }; if target == root || !target.starts_with(&root) { return false; } match (fs::canonicalize(&root), fs::canonicalize(&target)) { (Ok(real_root), Ok(real_target)) => real_target != real_root && real_target.starts_with(&real_root), (Ok(_), Err(_)) => true, // target does not exist yet; lexical check already passed _ => false, } } pub fn remove_cached_file(root: &Path, file: &Path) -> CoreResult { let root = resolved_cache_root(root)?; let target = std::path::absolute(file)?; if !is_strictly_inside(&root, &target) { return Err(CoreError::plain("Downloaded package path is outside its cache.")); } let Ok(meta) = fs::symlink_metadata(&target) else { return Ok(false) }; if !meta.is_file() { return Err(CoreError::plain("Downloaded package is not a regular file.")); } fs::remove_file(&target)?; let mut current = target.parent().map(Path::to_path_buf); while let Some(dir) = current { if dir == root || fs::remove_dir(&dir).is_err() { break; } current = dir.parent().map(Path::to_path_buf); } Ok(true) } #[cfg(test)] mod tests { use super::*; #[test] fn atomic_write_replaces_and_is_private() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("a").join("config.json"); atomic_write_json(&path, &serde_json::json!({"a": 1})).unwrap(); atomic_write_json(&path, &serde_json::json!({"a": 2})).unwrap(); assert_eq!(read_json(&path).unwrap()["a"], 2); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); } let leftovers: Vec<_> = fs::read_dir(path.parent().unwrap()).unwrap().collect(); assert_eq!(leftovers.len(), 1); } #[test] fn cache_removal_stays_inside_root() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("cache"); clean_download_cache(&root).unwrap(); let outside = dir.path().join("outside.bin"); fs::write(&outside, b"x").unwrap(); assert!(remove_cached_file(&root, &outside).is_err()); let inside = root.join("x").join("pkg.bin"); fs::create_dir_all(inside.parent().unwrap()).unwrap(); fs::write(&inside, b"x").unwrap(); assert!(remove_cached_file(&root, &inside).unwrap()); assert!(!root.join("x").exists()); assert!(root.exists()); } }