1#[cfg(test)]
8mod tests;
9
10use std::{io, path::Path};
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13enum FileCommitMode {
14 Replace,
15 CreateNew,
16}
17
18pub fn write_bytes(path: &Path, bytes: &[u8]) -> io::Result<()> {
23 commit_bytes(path, bytes, FileCommitMode::Replace)
24}
25
26pub fn create_new_bytes(path: &Path, bytes: &[u8]) -> io::Result<()> {
31 commit_bytes(path, bytes, FileCommitMode::CreateNew)
32}
33
34fn commit_bytes(path: &Path, bytes: &[u8], mode: FileCommitMode) -> io::Result<()> {
35 #[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
36 {
37 supported::commit_with_hook(path, bytes, mode, |_, _| Ok(()))
38 }
39
40 #[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple")))]
41 {
42 let _ = (path, bytes, mode);
43 Err(io::Error::new(
44 io::ErrorKind::Unsupported,
45 format!(
46 "durable atomic file publication is unsupported on platform {}",
47 std::env::consts::OS
48 ),
49 ))
50 }
51}
52
53#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
54mod supported {
55 use super::FileCommitMode;
56
57 use std::{
58 ffi::{OsStr, OsString},
59 fs,
60 io::{self, Write},
61 path::{Path, PathBuf},
62 sync::atomic::{AtomicU64, Ordering},
63 };
64
65 use rustix::{
66 fd::{AsFd, OwnedFd},
67 fs::{self as unix_fs, AtFlags, Mode, OFlags, RenameFlags},
68 };
69
70 const TEMP_ATTEMPTS: usize = 64;
71 static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
72
73 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
74 pub(super) enum FileCommitStep {
75 ParentDirectoryCreate,
76 CreatedDirectorySync,
77 CreatedDirectoryParentSync,
78 TemporaryFileCreate,
79 TemporaryFileWrite,
80 TemporaryFileSync,
81 Publication,
82 FinalParentSync,
83 }
84
85 pub(super) fn commit_with_hook(
86 path: &Path,
87 bytes: &[u8],
88 mode: FileCommitMode,
89 mut before: impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
90 ) -> io::Result<()> {
91 let (parent, file_name) = split_target(path)?;
92 if mode == FileCommitMode::Replace {
93 create_parent_hierarchy(parent, &mut before)?;
94 }
95 let parent_fd = open_directory(parent)?;
96 let (temp_name, temp_path, mut temp_file) =
97 create_sibling_temp(&parent_fd, parent, file_name, &mut before)?;
98
99 let staged = (|| {
100 before(FileCommitStep::TemporaryFileWrite, &temp_path)?;
101 temp_file.write_all(bytes)?;
102 before(FileCommitStep::TemporaryFileSync, &temp_path)?;
103 temp_file.sync_all()
104 })();
105 drop(temp_file);
106 if let Err(error) = staged {
107 remove_temp(&parent_fd, &temp_name);
108 return Err(error);
109 }
110
111 if let Err(error) = before(FileCommitStep::Publication, path) {
112 remove_temp(&parent_fd, &temp_name);
113 return Err(error);
114 }
115 let published = match mode {
116 FileCommitMode::Replace => {
117 unix_fs::renameat(&parent_fd, &temp_name, &parent_fd, file_name)
118 }
119 FileCommitMode::CreateNew => unix_fs::renameat_with(
120 &parent_fd,
121 &temp_name,
122 &parent_fd,
123 file_name,
124 RenameFlags::NOREPLACE,
125 ),
126 };
127 if let Err(error) = published {
128 remove_temp(&parent_fd, &temp_name);
129 return Err(errno_to_io(error));
130 }
131
132 before(FileCommitStep::FinalParentSync, parent)?;
133 unix_fs::fsync(&parent_fd).map_err(errno_to_io)
134 }
135
136 fn split_target(path: &Path) -> io::Result<(&Path, &OsStr)> {
137 let file_name = path.file_name().ok_or_else(|| {
138 io::Error::new(
139 io::ErrorKind::InvalidInput,
140 format!("durable write target has no file name: {}", path.display()),
141 )
142 })?;
143 let parent = path
144 .parent()
145 .filter(|parent| !parent.as_os_str().is_empty())
146 .unwrap_or_else(|| Path::new("."));
147 Ok((parent, file_name))
148 }
149
150 fn create_parent_hierarchy(
151 parent: &Path,
152 before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
153 ) -> io::Result<()> {
154 let mut missing = Vec::new();
155 let mut current = parent;
156 loop {
157 match fs::metadata(current) {
158 Ok(metadata) if metadata.is_dir() => break,
159 Ok(_) => {
160 return Err(io::Error::new(
161 io::ErrorKind::NotADirectory,
162 format!("output parent is not a directory: {}", current.display()),
163 ));
164 }
165 Err(error) if error.kind() == io::ErrorKind::NotFound => {
166 missing.push(current.to_path_buf());
167 current = current
168 .parent()
169 .filter(|ancestor| !ancestor.as_os_str().is_empty())
170 .unwrap_or_else(|| Path::new("."));
171 }
172 Err(error) => return Err(error),
173 }
174 }
175
176 for directory in missing.into_iter().rev() {
177 before(FileCommitStep::ParentDirectoryCreate, &directory)?;
178 match fs::create_dir(&directory) {
179 Ok(()) => sync_created_directory(&directory, before)?,
180 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
181 if !fs::metadata(&directory)?.is_dir() {
182 return Err(io::Error::new(
183 io::ErrorKind::NotADirectory,
184 format!("output parent is not a directory: {}", directory.display()),
185 ));
186 }
187 }
188 Err(error) => return Err(error),
189 }
190 }
191 Ok(())
192 }
193
194 fn sync_created_directory(
195 directory: &Path,
196 before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
197 ) -> io::Result<()> {
198 before(FileCommitStep::CreatedDirectorySync, directory)?;
199 let directory_fd = open_directory(directory)?;
200 unix_fs::fsync(&directory_fd).map_err(errno_to_io)?;
201
202 let owner = directory
203 .parent()
204 .filter(|parent| !parent.as_os_str().is_empty())
205 .unwrap_or_else(|| Path::new("."));
206 before(FileCommitStep::CreatedDirectoryParentSync, owner)?;
207 let owner_fd = open_directory(owner)?;
208 unix_fs::fsync(&owner_fd).map_err(errno_to_io)
209 }
210
211 fn create_sibling_temp(
212 parent_fd: &impl AsFd,
213 parent: &Path,
214 file_name: &OsStr,
215 before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
216 ) -> io::Result<(OsString, PathBuf, fs::File)> {
217 for _ in 0..TEMP_ATTEMPTS {
218 let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
219 let mut temp_name = OsString::from(".");
220 temp_name.push(file_name);
221 temp_name.push(format!(".canic-tmp-{}-{sequence}", std::process::id()));
222 let temp_path = parent.join(&temp_name);
223 before(FileCommitStep::TemporaryFileCreate, &temp_path)?;
224 match unix_fs::openat(
225 parent_fd,
226 &temp_name,
227 OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC,
228 Mode::from_raw_mode(0o666),
229 ) {
230 Ok(file) => return Ok((temp_name, temp_path, fs::File::from(file))),
231 Err(error) if error == rustix::io::Errno::EXIST => {}
232 Err(error) => return Err(errno_to_io(error)),
233 }
234 }
235
236 Err(io::Error::new(
237 io::ErrorKind::AlreadyExists,
238 format!(
239 "could not allocate a unique sibling temporary file for {}",
240 parent.join(file_name).display()
241 ),
242 ))
243 }
244
245 fn open_directory(path: &Path) -> io::Result<OwnedFd> {
246 unix_fs::open(
247 path,
248 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
249 Mode::empty(),
250 )
251 .map_err(errno_to_io)
252 }
253
254 fn remove_temp(parent_fd: &impl AsFd, temp_name: &OsStr) {
255 let _ = unix_fs::unlinkat(parent_fd, temp_name, AtFlags::empty());
256 }
257
258 fn errno_to_io(error: rustix::io::Errno) -> io::Error {
259 io::Error::from_raw_os_error(error.raw_os_error())
260 }
261}