Skip to main content

wist_shared/
fs.rs

1//! Shared filesystem helpers.
2
3use std::fs::{self, File};
4use std::io::{self, Write};
5use std::path::Path;
6
7use serde::Serialize;
8use serde::de::DeserializeOwned;
9
10pub fn ensure_parent(path: &Path) -> io::Result<()> {
11    if let Some(parent) = path.parent() {
12        fs::create_dir_all(parent)?;
13    }
14    Ok(())
15}
16
17pub fn read_json<T>(path: &Path) -> io::Result<T>
18where
19    T: DeserializeOwned,
20{
21    let text = fs::read_to_string(path)?;
22    serde_json::from_str(&text).map_err(io::Error::other)
23}
24
25pub fn write_json_atomic<T>(path: &Path, value: &T) -> io::Result<()>
26where
27    T: Serialize,
28{
29    let bytes = serde_json::to_vec_pretty(value).map_err(io::Error::other)?;
30    write_bytes_atomic(path, &bytes)
31}
32
33pub fn write_json_private_atomic<T>(path: &Path, value: &T) -> io::Result<()>
34where
35    T: Serialize,
36{
37    let bytes = serde_json::to_vec_pretty(value).map_err(io::Error::other)?;
38    write_bytes_private_atomic(path, &bytes)
39}
40
41pub fn write_json_compact_atomic<T>(path: &Path, value: &T) -> io::Result<()>
42where
43    T: Serialize,
44{
45    let bytes = serde_json::to_vec(value).map_err(io::Error::other)?;
46    write_bytes_atomic(path, &bytes)
47}
48
49/// 原子写字节:先写临时文件 `*.tmp`、fsync、再 rename 覆盖目标,最后 fsync 父目录。
50///
51/// 注意:**总是追加一个结尾换行**(`b"\n"`)—— 本仓的调用方据此把 JSON 写成行式文件;
52/// 若要写不含换行的二进制,请自行处理。父目录会自动创建。
53pub fn write_bytes_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
54    ensure_parent(path)?;
55
56    let tmp_path = path.with_extension("tmp");
57    let mut file = File::create(&tmp_path)?;
58    file.write_all(bytes)?;
59    file.write_all(b"\n")?;
60    file.sync_all()?;
61    drop(file);
62
63    fs::rename(&tmp_path, path)?;
64    sync_parent_dir(path)?;
65    Ok(())
66}
67
68/// 同 [`write_bytes_atomic`],但把临时文件与最终文件设为 0600,并把**父目录**收为 0700
69/// (敏感文件如 `agent_runtime.json` 落盘用)。裸文件名(无父目录)时跳过父目录改权限。
70#[cfg(unix)]
71pub fn write_bytes_private_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
72    use std::fs::OpenOptions;
73    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
74
75    ensure_parent(path)?;
76    // 裸文件名(无目录部分)的 parent 是空路径:`set_permissions("")` 会 ENOENT。
77    if let Some(parent) = non_empty_parent(path) {
78        fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
79    }
80
81    let tmp_path = path.with_extension("tmp");
82    let mut file = OpenOptions::new()
83        .create(true)
84        .truncate(true)
85        .write(true)
86        .mode(0o600)
87        .open(&tmp_path)?;
88    file.write_all(bytes)?;
89    file.write_all(b"\n")?;
90    file.sync_all()?;
91    drop(file);
92
93    fs::rename(&tmp_path, path)?;
94    fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
95    sync_parent_dir(path)?;
96    Ok(())
97}
98
99#[cfg(not(unix))]
100pub fn write_bytes_private_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
101    write_bytes_atomic(path, bytes)
102}
103
104/// 取父目录,但把"空路径"(裸文件名的父)视为没有父目录。
105///
106/// `Path::new("a.json").parent()` 返回 `Some("")`,而 `File::open("")` / `set_permissions("")`
107/// 都会 ENOENT —— 若直接使用,会让"写成功"却返回错误(`sync_parent_dir`)。
108#[cfg(unix)]
109fn non_empty_parent(path: &Path) -> Option<&Path> {
110    path.parent()
111        .filter(|parent| !parent.as_os_str().is_empty())
112}
113
114#[cfg(unix)]
115fn sync_parent_dir(path: &Path) -> io::Result<()> {
116    if let Some(parent) = non_empty_parent(path) {
117        File::open(parent)?.sync_all()?;
118    }
119    Ok(())
120}
121
122#[cfg(not(unix))]
123fn sync_parent_dir(_path: &Path) -> io::Result<()> {
124    Ok(())
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use std::path::PathBuf;
131    use std::sync::atomic::{AtomicU64, Ordering};
132
133    static COUNTER: AtomicU64 = AtomicU64::new(0);
134
135    /// 每个测试一个独立临时目录(不依赖外部 crate)。
136    fn temp_dir(tag: &str) -> PathBuf {
137        let unique = format!(
138            "wist-shared-{tag}-{}-{}",
139            std::process::id(),
140            COUNTER.fetch_add(1, Ordering::Relaxed)
141        );
142        let dir = std::env::temp_dir().join(unique);
143        fs::create_dir_all(&dir).expect("create temp dir");
144        dir
145    }
146
147    #[test]
148    fn write_then_read_json_round_trips() {
149        #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
150        struct Payload {
151            name: String,
152            count: u32,
153        }
154        let dir = temp_dir("json-round-trip");
155        let path = dir.join("nested/deeper/data.json");
156        let value = Payload {
157            name: "wist".into(),
158            count: 7,
159        };
160        write_json_atomic(&path, &value).expect("write");
161        assert!(path.exists(), "父目录应被自动创建");
162        let read: Payload = read_json(&path).expect("read");
163        assert_eq!(read, value);
164        fs::remove_dir_all(&dir).ok();
165    }
166
167    #[test]
168    fn compact_json_has_no_pretty_whitespace_and_ends_with_newline() {
169        let dir = temp_dir("json-compact");
170        let path = dir.join("c.json");
171        write_json_compact_atomic(&path, &[1, 2, 3]).expect("write");
172        let text = fs::read_to_string(&path).expect("read");
173        assert_eq!(text, "[1,2,3]\n");
174        fs::remove_dir_all(&dir).ok();
175    }
176
177    #[test]
178    fn pretty_json_ends_with_exactly_one_newline() {
179        let dir = temp_dir("json-pretty");
180        let path = dir.join("p.json");
181        write_json_atomic(&path, &serde_json::json!({ "a": 1 })).expect("write");
182        let text = fs::read_to_string(&path).expect("read");
183        assert!(text.ends_with('\n'));
184        assert!(!text.ends_with("\n\n"));
185        // 美化输出应当能被读回。
186        let value: serde_json::Value = read_json(&path).expect("read");
187        assert_eq!(value["a"], serde_json::json!(1));
188        fs::remove_dir_all(&dir).ok();
189    }
190
191    #[test]
192    fn write_bytes_appends_exactly_one_trailing_newline() {
193        let dir = temp_dir("bytes-newline");
194        let path = dir.join("b.bin");
195        write_bytes_atomic(&path, b"payload").expect("write");
196        assert_eq!(fs::read(&path).expect("read"), b"payload\n");
197        fs::remove_dir_all(&dir).ok();
198    }
199
200    #[test]
201    fn read_json_on_invalid_or_missing_input_is_an_error_not_a_panic() {
202        let dir = temp_dir("json-errors");
203        let missing = dir.join("nope.json");
204        assert!(read_json::<serde_json::Value>(&missing).is_err());
205
206        let broken = dir.join("broken.json");
207        write_bytes_atomic(&broken, b"{ not json").expect("write");
208        assert!(read_json::<serde_json::Value>(&broken).is_err());
209        fs::remove_dir_all(&dir).ok();
210    }
211
212    /// 裸文件名(无目录部分)也必须能写:`ensure_parent` 容忍空父路径,
213    /// 但旧实现的 `sync_parent_dir` 会 `File::open("")` 报 ENOENT,让写成功后返回 Err。
214    #[test]
215    fn bare_relative_filename_is_writable() {
216        let dir = temp_dir("bare");
217        let guard = CwdGuard::enter(&dir);
218
219        write_json_atomic(Path::new("bare.json"), &serde_json::json!({ "ok": true }))
220            .expect("裸文件名应当可写");
221        let value: serde_json::Value = read_json(Path::new("bare.json")).expect("read");
222        assert_eq!(value["ok"], serde_json::json!(true));
223        assert!(!Path::new("bare.tmp").exists(), "不应残留临时文件");
224
225        drop(guard);
226        fs::remove_dir_all(&dir).ok();
227    }
228
229    #[test]
230    fn empty_parent_has_no_directory_to_sync() {
231        // 私有 helper 的直接钉桩:空父路径不是错误(这正是上面裸文件名路径依赖的修复)。
232        assert_eq!(non_empty_parent(Path::new("bare.json")), None);
233        assert_eq!(
234            non_empty_parent(Path::new("dir/a.json")),
235            Some(Path::new("dir"))
236        );
237        #[cfg(unix)]
238        assert!(sync_parent_dir(Path::new("bare.json")).is_ok());
239    }
240
241    #[cfg(unix)]
242    #[test]
243    fn private_writes_restrict_file_and_parent_permissions() {
244        use std::os::unix::fs::PermissionsExt;
245        let dir = temp_dir("private");
246        let path = dir.join("secret.json");
247        write_json_private_atomic(&path, &serde_json::json!({ "token": "x" })).expect("write");
248
249        let file_mode = fs::metadata(&path).expect("meta").permissions().mode() & 0o777;
250        assert_eq!(file_mode, 0o600, "私有文件应为 0600");
251        let dir_mode = fs::metadata(&dir).expect("meta").permissions().mode() & 0o777;
252        assert_eq!(dir_mode, 0o700, "父目录应被收紧为 0700");
253
254        let value: serde_json::Value = read_json(&path).expect("read");
255        assert_eq!(value["token"], serde_json::json!("x"));
256        fs::remove_dir_all(&dir).ok();
257    }
258
259    /// 进入临时目录并在 drop 时切回;配合互斥避免测试并行时的 CWD 竞争。
260    struct CwdGuard {
261        original: PathBuf,
262        _lock: std::sync::MutexGuard<'static, ()>,
263    }
264
265    static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
266
267    impl CwdGuard {
268        fn enter(dir: &Path) -> Self {
269            let lock = CWD_LOCK.lock().unwrap_or_else(|error| error.into_inner());
270            let original = std::env::current_dir().expect("cwd");
271            std::env::set_current_dir(dir).expect("chdir");
272            Self {
273                original,
274                _lock: lock,
275            }
276        }
277    }
278
279    impl Drop for CwdGuard {
280        fn drop(&mut self) {
281            std::env::set_current_dir(&self.original).ok();
282        }
283    }
284}