sqry_core/persistence/
atomic_write.rs1use std::io::{self, Write as _};
36use std::path::Path;
37
38use tempfile::NamedTempFile;
39
40pub fn atomic_write_bytes(target_path: &Path, bytes: &[u8]) -> io::Result<()> {
64 let (parent, canonical_parent) = validate_atomic_write_target(target_path)?;
65 write_and_persist_tempfile(&parent, target_path, bytes)?;
66 fsync_parent_dir(&canonical_parent)?;
67
68 Ok(())
69}
70
71fn validate_atomic_write_target(
72 target_path: &Path,
73) -> io::Result<(std::path::PathBuf, std::path::PathBuf)> {
74 reject_target_symlink(target_path)?;
75
76 let parent = target_path.parent().ok_or_else(|| {
77 io::Error::new(
78 io::ErrorKind::InvalidInput,
79 format!(
80 "atomic_write_bytes: target path has no parent directory: {}",
81 target_path.display()
82 ),
83 )
84 })?;
85 reject_parent_symlink(parent)?;
86 let canonical_parent = canonical_parent_dir(parent)?;
87
88 Ok((parent.to_path_buf(), canonical_parent))
89}
90
91fn reject_target_symlink(target_path: &Path) -> io::Result<()> {
92 if let Ok(meta) = std::fs::symlink_metadata(target_path)
93 && meta.file_type().is_symlink()
94 {
95 return Err(io::Error::new(
96 io::ErrorKind::InvalidInput,
97 format!(
98 "atomic_write_bytes: target path is a symlink and will not be followed: {}",
99 target_path.display()
100 ),
101 ));
102 }
103 Ok(())
104}
105
106fn reject_parent_symlink(parent: &Path) -> io::Result<()> {
107 let raw_parent_meta = std::fs::symlink_metadata(parent).map_err(|e| {
108 io::Error::new(
109 e.kind(),
110 format!(
111 "atomic_write_bytes: cannot stat parent directory '{}': {e}",
112 parent.display()
113 ),
114 )
115 })?;
116 if raw_parent_meta.file_type().is_symlink() {
117 return Err(io::Error::new(
118 io::ErrorKind::InvalidInput,
119 format!(
120 "atomic_write_bytes: parent directory is a symlink and will not be followed: {}",
121 parent.display()
122 ),
123 ));
124 }
125 Ok(())
126}
127
128fn canonical_parent_dir(parent: &Path) -> io::Result<std::path::PathBuf> {
129 let canonical_parent = parent.canonicalize().map_err(|e| {
130 io::Error::new(
131 e.kind(),
132 format!(
133 "atomic_write_bytes: cannot canonicalize parent directory '{}': {e}",
134 parent.display()
135 ),
136 )
137 })?;
138 let canon_meta = std::fs::symlink_metadata(&canonical_parent).map_err(|e| {
140 io::Error::new(
141 e.kind(),
142 format!(
143 "atomic_write_bytes: cannot stat canonical parent '{}': {e}",
144 canonical_parent.display()
145 ),
146 )
147 })?;
148 if !canon_meta.is_dir() {
149 return Err(io::Error::new(
150 io::ErrorKind::NotADirectory,
151 format!(
152 "atomic_write_bytes: canonical parent path is not a directory: {}",
153 canonical_parent.display()
154 ),
155 ));
156 }
157 Ok(canonical_parent)
158}
159
160fn write_and_persist_tempfile(parent: &Path, target_path: &Path, bytes: &[u8]) -> io::Result<()> {
161 let mut tmp = NamedTempFile::new_in(parent).map_err(|e| {
162 io::Error::new(
163 e.kind(),
164 format!(
165 "atomic_write_bytes: failed to create tempfile in '{}': {e}",
166 parent.display()
167 ),
168 )
169 })?;
170
171 if let Err(write_err) = tmp.write_all(bytes) {
172 let _ = tmp.close();
173 return Err(io::Error::new(
174 write_err.kind(),
175 format!("atomic_write_bytes: write failed: {write_err}"),
176 ));
177 }
178
179 if let Err(sync_err) = tmp.as_file().sync_all() {
180 let _ = tmp.close();
181 return Err(io::Error::new(
182 sync_err.kind(),
183 format!("atomic_write_bytes: fsync(file) failed: {sync_err}"),
184 ));
185 }
186
187 tmp.persist(target_path).map_err(|persist_err| {
193 let _ = persist_err.file.close();
196 io::Error::new(
197 persist_err.error.kind(),
198 format!(
199 "atomic_write_bytes: rename to '{}' failed: {}",
200 target_path.display(),
201 persist_err.error
202 ),
203 )
204 })?;
205 Ok(())
206}
207
208#[cfg(unix)]
214fn fsync_parent_dir(canonical_parent: &Path) -> io::Result<()> {
215 use std::fs::OpenOptions;
216 let dir_file = OpenOptions::new()
217 .read(true)
218 .open(canonical_parent)
219 .map_err(|e| {
220 io::Error::new(
221 e.kind(),
222 format!(
223 "atomic_write_bytes: cannot open parent dir for fsync '{}': {e}",
224 canonical_parent.display()
225 ),
226 )
227 })?;
228 dir_file.sync_all().map_err(|e| {
229 io::Error::new(
230 e.kind(),
231 format!(
232 "atomic_write_bytes: fsync(parent_dir) failed for '{}': {e}",
233 canonical_parent.display()
234 ),
235 )
236 })
237}
238
239#[cfg(not(unix))]
244#[allow(clippy::unnecessary_wraps)]
245fn fsync_parent_dir(_canonical_parent: &Path) -> io::Result<()> {
246 Ok(())
247}
248
249#[cfg(test)]
254mod tests {
255 use std::fs;
256
257 use tempfile::TempDir;
258
259 use super::*;
260
261 fn tmp_dir() -> TempDir {
265 TempDir::new().expect("TempDir::new failed")
266 }
267
268 #[test]
273 fn atomic_write_happy_path() {
274 let dir = tmp_dir();
275 let target = dir.path().join("output.bin");
276 let content = b"hello atomic world";
277
278 assert!(!target.exists(), "pre-condition: target must not exist");
280
281 atomic_write_bytes(&target, content).expect("atomic_write_bytes failed");
282
283 let read_back = fs::read(&target).expect("read back failed");
285 assert_eq!(read_back, content, "content mismatch after atomic write");
286
287 let entries: Vec<_> = fs::read_dir(dir.path())
289 .expect("read_dir failed")
290 .filter_map(|e| e.ok())
291 .collect();
292 assert_eq!(
294 entries.len(),
295 1,
296 "unexpected files left in parent dir: {entries:?}"
297 );
298 assert_eq!(
299 entries[0].path(),
300 target,
301 "the only file in parent should be the target"
302 );
303 }
304
305 #[test]
309 fn atomic_write_overwrites_existing_regular_file() {
310 let dir = tmp_dir();
311 let target = dir.path().join("existing.txt");
312 let old_content = b"old content";
313 let new_content = b"new content -- replaced atomically";
314
315 fs::write(&target, old_content).expect("pre-write failed");
317 assert!(target.is_file(), "pre-condition: target is a regular file");
318
319 atomic_write_bytes(&target, new_content).expect("atomic_write_bytes failed on overwrite");
320
321 let read_back = fs::read(&target).expect("read back failed");
322 assert_eq!(read_back, new_content, "content should have been replaced");
323 }
324
325 #[cfg(unix)]
330 #[test]
331 fn atomic_write_rejects_symlink_target() {
332 let dir = tmp_dir();
333 let real_file = dir.path().join("real.txt");
334 let symlink_target = dir.path().join("link.txt");
335
336 fs::write(&real_file, b"original").expect("pre-write failed");
338 std::os::unix::fs::symlink(&real_file, &symlink_target).expect("symlink creation failed");
339
340 assert!(
341 symlink_target
342 .symlink_metadata()
343 .map(|m| m.file_type().is_symlink())
344 .unwrap_or(false),
345 "pre-condition: symlink_target must be a symlink"
346 );
347
348 let result = atomic_write_bytes(&symlink_target, b"new bytes");
349 assert!(result.is_err(), "expected Err for symlink target, got Ok");
350
351 let real_content = fs::read(&real_file).expect("read real_file failed");
353 assert_eq!(real_content, b"original", "real file must not be modified");
354
355 let lmeta = symlink_target
357 .symlink_metadata()
358 .expect("symlink should still exist");
359 assert!(
360 lmeta.file_type().is_symlink(),
361 "symlink must remain a symlink"
362 );
363 }
364
365 #[cfg(unix)]
373 #[test]
374 fn atomic_write_rejects_symlink_parent() {
375 let dir = tmp_dir();
376 let real_subdir = dir.path().join("real_subdir");
378 let link_subdir = dir.path().join("link_subdir");
379 fs::create_dir(&real_subdir).expect("create real_subdir failed");
380 std::os::unix::fs::symlink(&real_subdir, &link_subdir)
381 .expect("symlink to directory failed");
382
383 let target = link_subdir.join("output.txt");
385
386 let result = atomic_write_bytes(&target, b"should not be written");
387 assert!(result.is_err(), "expected Err for symlink parent, got Ok");
388
389 assert!(
391 !real_subdir.join("output.txt").exists(),
392 "file must not be created in real_subdir"
393 );
394 }
395
396 #[cfg(unix)]
406 #[test]
407 fn atomic_write_temp_cleanup_on_failure() {
408 use std::os::unix::fs::PermissionsExt as _;
409
410 let dir = tmp_dir();
411
412 let readonly_dir = dir.path().join("readonly");
420 fs::create_dir(&readonly_dir).expect("create readonly_dir failed");
421
422 let mut perms = fs::metadata(&readonly_dir)
424 .expect("stat readonly_dir")
425 .permissions();
426 perms.set_mode(0o500); fs::set_permissions(&readonly_dir, perms).expect("chmod failed");
428
429 let target = readonly_dir.join("output.txt");
430 let result = atomic_write_bytes(&target, b"data");
431 assert!(
432 result.is_err(),
433 "expected Err when rename into read-only dir"
434 );
435
436 let mut perms = fs::metadata(&readonly_dir)
438 .expect("stat readonly_dir")
439 .permissions();
440 perms.set_mode(0o700);
441 fs::set_permissions(&readonly_dir, perms).ok();
442
443 let remaining: Vec<_> = fs::read_dir(&readonly_dir)
445 .expect("read_dir readonly_dir")
446 .filter_map(|e| e.ok())
447 .collect();
448 assert!(
449 remaining.is_empty(),
450 "no tempfile should remain after failure: {remaining:?}"
451 );
452 }
453}