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