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