blockworx_store/
atomic.rs1use std::io::Write as _;
10use std::path::{Path, PathBuf};
11
12pub fn write_atomically(path: &Path, contents: &[u8]) -> std::io::Result<()> {
19 let target = resolve(path);
20 let temp = temp_path(&target)?;
21 write_and_replace(&temp, &target, contents).inspect_err(|_| {
22 let _ = std::fs::remove_file(&temp);
25 })
26}
27
28fn resolve(path: &Path) -> PathBuf {
32 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
33}
34
35fn temp_path(target: &Path) -> std::io::Result<PathBuf> {
38 let name = target.file_name().ok_or_else(|| {
39 std::io::Error::new(
40 std::io::ErrorKind::InvalidInput,
41 format!("{} names no file to write", target.display()),
42 )
43 })?;
44 let mut temp = std::ffi::OsString::from(".");
45 temp.push(name);
46 temp.push(".tmp");
47 Ok(target.with_file_name(temp))
48}
49
50fn write_and_replace(temp: &Path, target: &Path, contents: &[u8]) -> std::io::Result<()> {
51 {
52 let mut file = std::fs::File::create(temp)?;
53 file.write_all(contents)?;
54 file.sync_all()?;
58 }
59 std::fs::rename(temp, target)?;
60 sync_dir(parent_dir(target))?;
61 Ok(())
62}
63
64fn parent_dir(target: &Path) -> &Path {
67 match target.parent() {
68 Some(dir) if !dir.as_os_str().is_empty() => dir,
69 _ => Path::new("."),
70 }
71}
72
73#[cfg(unix)]
76fn sync_dir(dir: &Path) -> std::io::Result<()> {
77 std::fs::File::open(dir)?.sync_all()
78}
79
80#[cfg(not(unix))]
84#[expect(clippy::unnecessary_wraps, reason = "the unix arm can fail")]
85fn sync_dir(_dir: &Path) -> std::io::Result<()> {
86 Ok(())
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use crate::temp::TempDir;
93
94 fn read(path: &Path) -> String {
95 std::fs::read_to_string(path).unwrap()
96 }
97
98 #[test]
99 fn it_creates_and_then_overwrites() {
100 let dir = TempDir::new("create-overwrite");
101 let doc = dir.join("doc.json");
102
103 write_atomically(&doc, b"first").unwrap();
104 assert_eq!(read(&doc), "first");
105
106 write_atomically(&doc, b"second").unwrap();
107 assert_eq!(read(&doc), "second");
108 }
109
110 #[test]
111 fn a_successful_write_leaves_no_temp_behind() {
112 let dir = TempDir::new("no-temp");
113 let doc = dir.join("doc.json");
114 write_atomically(&doc, b"content").unwrap();
115
116 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
117 .unwrap()
118 .map(|e| e.unwrap().file_name())
119 .filter(|name| name != "doc.json")
120 .collect();
121 assert!(leftovers.is_empty(), "left {leftovers:?} behind");
122 }
123
124 #[test]
129 fn a_failed_write_leaves_the_previous_contents_intact() {
130 let dir = TempDir::new("failed-write");
131 let doc = dir.join("doc.json");
132 write_atomically(&doc, b"the good version").unwrap();
133
134 let blocker = dir.join(".doc.json.tmp");
135 std::fs::create_dir(&blocker).unwrap();
136 assert_eq!(
140 temp_path(&resolve(&doc)).unwrap(),
141 std::fs::canonicalize(&blocker).unwrap(),
142 );
143
144 assert!(write_atomically(&doc, b"a doomed longer version").is_err());
145 assert_eq!(read(&doc), "the good version");
146 }
147
148 #[test]
149 fn a_path_naming_no_file_is_an_error() {
150 assert!(write_atomically(Path::new(".."), b"x").is_err());
151 }
152
153 #[cfg(unix)]
156 #[test]
157 fn writing_a_symlink_updates_its_target() {
158 let dir = TempDir::new("symlink");
159 let target = dir.join("real.json");
160 let link = dir.join("link.json");
161 write_atomically(&target, b"before").unwrap();
162 std::os::unix::fs::symlink(&target, &link).unwrap();
163
164 write_atomically(&link, b"after").unwrap();
165
166 assert!(
167 std::fs::symlink_metadata(&link).unwrap().is_symlink(),
168 "the link itself was replaced"
169 );
170 assert_eq!(read(&target), "after");
171 }
172}