1use std::fs::{self, File, OpenOptions};
10use std::io::{self, Write};
11use std::path::Path;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14pub fn write_atomic(path: &Path, contents: impl AsRef<[u8]>) -> io::Result<()> {
51 write_atomic_impl(path, contents, false)
52}
53
54pub fn write_atomic_private(path: &Path, contents: impl AsRef<[u8]>) -> io::Result<()> {
59 write_atomic_impl(path, contents, true)
60}
61
62fn write_atomic_impl(path: &Path, contents: impl AsRef<[u8]>, private: bool) -> io::Result<()> {
63 let parent = path.parent().ok_or_else(|| {
64 io::Error::new(
65 io::ErrorKind::InvalidInput,
66 "atomic write path has no parent",
67 )
68 })?;
69 fs::create_dir_all(parent)?;
70
71 let file_name = path.file_name().ok_or_else(|| {
72 io::Error::new(
73 io::ErrorKind::InvalidInput,
74 "atomic write path has no file name",
75 )
76 })?;
77 let nonce = SystemTime::now()
78 .duration_since(UNIX_EPOCH)
79 .map(|d| d.as_nanos())
80 .unwrap_or_default();
81 let tmp_name = format!(
82 ".{}.tmp-{}-{nonce}",
83 file_name.to_string_lossy(),
84 std::process::id()
85 );
86 let tmp_path = path.with_file_name(tmp_name);
87
88 let write_result = (|| -> io::Result<()> {
89 let mut file = create_temp_file(&tmp_path, private)?;
90 file.write_all(contents.as_ref())?;
91 file.sync_all()?;
92 drop(file);
93 fs::rename(&tmp_path, path)?;
94 sync_parent_dir(parent)?;
95 Ok(())
96 })();
97
98 if write_result.is_err() {
99 let _ = fs::remove_file(&tmp_path);
100 }
101 write_result
102}
103
104fn create_temp_file(path: &Path, private: bool) -> io::Result<File> {
105 let mut options = OpenOptions::new();
106 options.write(true).create_new(true);
107 #[cfg(unix)]
108 if private {
109 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
110 options.mode(0o600);
111 let file = options.open(path)?;
112 file.set_permissions(fs::Permissions::from_mode(0o600))?;
114 return Ok(file);
115 }
116 let _ = private;
117 options.open(path)
118}
119
120#[cfg(unix)]
121fn sync_parent_dir(parent: &Path) -> io::Result<()> {
122 File::open(parent)?.sync_all()
123}
124
125#[cfg(not(unix))]
126fn sync_parent_dir(_parent: &Path) -> io::Result<()> {
127 Ok(())
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 fn scratch(name: &str) -> std::path::PathBuf {
135 let dir = std::env::temp_dir().join(format!("choir-fs-{name}-{}", std::process::id()));
136 std::fs::remove_dir_all(&dir).ok();
137 std::fs::create_dir_all(&dir).unwrap();
138 dir
139 }
140
141 #[test]
142 fn creates_replaces_and_leaves_no_temp_files() {
143 let dir = scratch("atomic");
144 let path = dir.join("state.txt");
145
146 write_atomic(&path, "first").unwrap();
147 assert_eq!(fs::read_to_string(&path).unwrap(), "first");
148
149 write_atomic(&path, "second").unwrap();
150 assert_eq!(fs::read_to_string(&path).unwrap(), "second");
151
152 let leftovers: Vec<_> = fs::read_dir(&dir)
153 .unwrap()
154 .map(|entry| entry.unwrap().file_name())
155 .filter(|name| name.to_string_lossy().contains(".tmp-"))
156 .collect();
157 assert!(
158 leftovers.is_empty(),
159 "left temp files behind: {leftovers:?}"
160 );
161 std::fs::remove_dir_all(&dir).ok();
162 }
163
164 #[cfg(unix)]
165 #[test]
166 fn private_writes_are_owner_only_even_when_replacing_a_readable_file() {
167 use std::os::unix::fs::PermissionsExt;
168
169 let dir = scratch("private");
170 let path = dir.join("secret");
171 fs::write(&path, "old").unwrap();
172 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
173
174 write_atomic_private(&path, "new").unwrap();
175
176 assert_eq!(fs::read_to_string(&path).unwrap(), "new");
177 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
178 assert_eq!(mode, 0o600);
179 std::fs::remove_dir_all(&dir).ok();
180 }
181
182 #[test]
183 fn missing_parent_directories_are_created() {
184 let dir = scratch("mkdirs");
185 let path = dir.join("a/b/state.txt");
186 write_atomic(&path, "deep").unwrap();
187 assert_eq!(fs::read_to_string(&path).unwrap(), "deep");
188 std::fs::remove_dir_all(&dir).ok();
189 }
190}