1use 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
49pub 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#[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 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#[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 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 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 #[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 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 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}