use std::fs::{self, File};
use std::io::{self, Write};
use std::path::Path;
use serde::Serialize;
use serde::de::DeserializeOwned;
pub fn ensure_parent(path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
Ok(())
}
pub fn read_json<T>(path: &Path) -> io::Result<T>
where
T: DeserializeOwned,
{
let text = fs::read_to_string(path)?;
serde_json::from_str(&text).map_err(io::Error::other)
}
pub fn write_json_atomic<T>(path: &Path, value: &T) -> io::Result<()>
where
T: Serialize,
{
let bytes = serde_json::to_vec_pretty(value).map_err(io::Error::other)?;
write_bytes_atomic(path, &bytes)
}
pub fn write_json_private_atomic<T>(path: &Path, value: &T) -> io::Result<()>
where
T: Serialize,
{
let bytes = serde_json::to_vec_pretty(value).map_err(io::Error::other)?;
write_bytes_private_atomic(path, &bytes)
}
pub fn write_json_compact_atomic<T>(path: &Path, value: &T) -> io::Result<()>
where
T: Serialize,
{
let bytes = serde_json::to_vec(value).map_err(io::Error::other)?;
write_bytes_atomic(path, &bytes)
}
pub fn write_bytes_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
ensure_parent(path)?;
let tmp_path = path.with_extension("tmp");
let mut file = File::create(&tmp_path)?;
file.write_all(bytes)?;
file.write_all(b"\n")?;
file.sync_all()?;
drop(file);
fs::rename(&tmp_path, path)?;
sync_parent_dir(path)?;
Ok(())
}
#[cfg(unix)]
pub fn write_bytes_private_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
use std::fs::OpenOptions;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
ensure_parent(path)?;
if let Some(parent) = non_empty_parent(path) {
fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
}
let tmp_path = path.with_extension("tmp");
let mut file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
.open(&tmp_path)?;
file.write_all(bytes)?;
file.write_all(b"\n")?;
file.sync_all()?;
drop(file);
fs::rename(&tmp_path, path)?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
sync_parent_dir(path)?;
Ok(())
}
#[cfg(not(unix))]
pub fn write_bytes_private_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
write_bytes_atomic(path, bytes)
}
#[cfg(unix)]
fn non_empty_parent(path: &Path) -> Option<&Path> {
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
}
#[cfg(unix)]
fn sync_parent_dir(path: &Path) -> io::Result<()> {
if let Some(parent) = non_empty_parent(path) {
File::open(parent)?.sync_all()?;
}
Ok(())
}
#[cfg(not(unix))]
fn sync_parent_dir(_path: &Path) -> io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_dir(tag: &str) -> PathBuf {
let unique = format!(
"wist-shared-{tag}-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
);
let dir = std::env::temp_dir().join(unique);
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
#[test]
fn write_then_read_json_round_trips() {
#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
struct Payload {
name: String,
count: u32,
}
let dir = temp_dir("json-round-trip");
let path = dir.join("nested/deeper/data.json");
let value = Payload {
name: "wist".into(),
count: 7,
};
write_json_atomic(&path, &value).expect("write");
assert!(path.exists(), "父目录应被自动创建");
let read: Payload = read_json(&path).expect("read");
assert_eq!(read, value);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn compact_json_has_no_pretty_whitespace_and_ends_with_newline() {
let dir = temp_dir("json-compact");
let path = dir.join("c.json");
write_json_compact_atomic(&path, &[1, 2, 3]).expect("write");
let text = fs::read_to_string(&path).expect("read");
assert_eq!(text, "[1,2,3]\n");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn pretty_json_ends_with_exactly_one_newline() {
let dir = temp_dir("json-pretty");
let path = dir.join("p.json");
write_json_atomic(&path, &serde_json::json!({ "a": 1 })).expect("write");
let text = fs::read_to_string(&path).expect("read");
assert!(text.ends_with('\n'));
assert!(!text.ends_with("\n\n"));
let value: serde_json::Value = read_json(&path).expect("read");
assert_eq!(value["a"], serde_json::json!(1));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn write_bytes_appends_exactly_one_trailing_newline() {
let dir = temp_dir("bytes-newline");
let path = dir.join("b.bin");
write_bytes_atomic(&path, b"payload").expect("write");
assert_eq!(fs::read(&path).expect("read"), b"payload\n");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn read_json_on_invalid_or_missing_input_is_an_error_not_a_panic() {
let dir = temp_dir("json-errors");
let missing = dir.join("nope.json");
assert!(read_json::<serde_json::Value>(&missing).is_err());
let broken = dir.join("broken.json");
write_bytes_atomic(&broken, b"{ not json").expect("write");
assert!(read_json::<serde_json::Value>(&broken).is_err());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn bare_relative_filename_is_writable() {
let dir = temp_dir("bare");
let guard = CwdGuard::enter(&dir);
write_json_atomic(Path::new("bare.json"), &serde_json::json!({ "ok": true }))
.expect("裸文件名应当可写");
let value: serde_json::Value = read_json(Path::new("bare.json")).expect("read");
assert_eq!(value["ok"], serde_json::json!(true));
assert!(!Path::new("bare.tmp").exists(), "不应残留临时文件");
drop(guard);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn empty_parent_has_no_directory_to_sync() {
assert_eq!(non_empty_parent(Path::new("bare.json")), None);
assert_eq!(
non_empty_parent(Path::new("dir/a.json")),
Some(Path::new("dir"))
);
#[cfg(unix)]
assert!(sync_parent_dir(Path::new("bare.json")).is_ok());
}
#[cfg(unix)]
#[test]
fn private_writes_restrict_file_and_parent_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("private");
let path = dir.join("secret.json");
write_json_private_atomic(&path, &serde_json::json!({ "token": "x" })).expect("write");
let file_mode = fs::metadata(&path).expect("meta").permissions().mode() & 0o777;
assert_eq!(file_mode, 0o600, "私有文件应为 0600");
let dir_mode = fs::metadata(&dir).expect("meta").permissions().mode() & 0o777;
assert_eq!(dir_mode, 0o700, "父目录应被收紧为 0700");
let value: serde_json::Value = read_json(&path).expect("read");
assert_eq!(value["token"], serde_json::json!("x"));
fs::remove_dir_all(&dir).ok();
}
struct CwdGuard {
original: PathBuf,
_lock: std::sync::MutexGuard<'static, ()>,
}
static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
impl CwdGuard {
fn enter(dir: &Path) -> Self {
let lock = CWD_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let original = std::env::current_dir().expect("cwd");
std::env::set_current_dir(dir).expect("chdir");
Self {
original,
_lock: lock,
}
}
}
impl Drop for CwdGuard {
fn drop(&mut self) {
std::env::set_current_dir(&self.original).ok();
}
}
}