1#[cfg(test)]
12mod tests;
13
14use std::{fs, io, path::Path};
15
16#[derive(Debug)]
17pub(crate) enum RegularFileReadError {
18 NotRegular,
19 Io(io::Error),
20 #[cfg(not(unix))]
21 UnsupportedPlatform,
22}
23
24#[derive(Debug)]
25pub(crate) enum BoundedRegularFileReadError {
26 Read(RegularFileReadError),
27 TooLarge,
28}
29
30#[derive(Debug)]
31pub(crate) enum RegularFileLockError {
32 NotRegular,
33 Io(io::Error),
34 #[cfg(windows)]
35 UnsupportedPlatform,
36}
37
38pub(crate) fn read_optional_regular_bytes(
40 path: &Path,
41) -> Result<Option<Vec<u8>>, RegularFileReadError> {
42 #[cfg(unix)]
43 {
44 supported::read_optional_regular_bytes(path)
45 }
46
47 #[cfg(not(unix))]
48 {
49 let _ = path;
50 Err(RegularFileReadError::UnsupportedPlatform)
51 }
52}
53
54pub(crate) fn read_optional_regular_bytes_bounded(
60 path: &Path,
61 maximum_bytes: usize,
62) -> Result<Option<Vec<u8>>, BoundedRegularFileReadError> {
63 #[cfg(unix)]
64 {
65 supported::read_optional_regular_bytes_bounded(path, maximum_bytes)
66 }
67
68 #[cfg(not(unix))]
69 {
70 let _ = (path, maximum_bytes);
71 Err(BoundedRegularFileReadError::Read(
72 RegularFileReadError::UnsupportedPlatform,
73 ))
74 }
75}
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78enum FileCommitMode {
79 Replace,
80 CreateNew,
81 CreateNewWithParents,
82}
83
84pub fn write_bytes(path: &Path, bytes: &[u8]) -> io::Result<()> {
89 commit_bytes(path, bytes, FileCommitMode::Replace)
90}
91
92pub fn create_new_bytes(path: &Path, bytes: &[u8]) -> io::Result<()> {
97 commit_bytes(path, bytes, FileCommitMode::CreateNew)
98}
99
100pub fn create_new_bytes_with_parents(path: &Path, bytes: &[u8]) -> io::Result<()> {
103 commit_bytes(path, bytes, FileCommitMode::CreateNewWithParents)
104}
105
106pub(crate) fn lock_regular_file_with_parents(
111 path: &Path,
112) -> Result<fs::File, RegularFileLockError> {
113 match create_new_bytes_with_parents(path, &[]) {
114 Ok(()) => {}
115 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
116 Err(source) => return Err(RegularFileLockError::Io(source)),
117 }
118 let metadata = fs::symlink_metadata(path).map_err(RegularFileLockError::Io)?;
119 if !metadata.file_type().is_file() {
120 return Err(RegularFileLockError::NotRegular);
121 }
122
123 #[cfg(not(windows))]
124 {
125 use rustix::{
126 fd::OwnedFd,
127 fs::{FileType, FlockOperation, Mode, OFlags, flock, fstat, open},
128 };
129
130 let fd: OwnedFd = open(
131 path,
132 OFlags::RDWR | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
133 Mode::empty(),
134 )
135 .map_err(errno_to_lock_error)?;
136 let metadata = fstat(&fd).map_err(errno_to_lock_error)?;
137 if FileType::from_raw_mode(metadata.st_mode) != FileType::RegularFile {
138 return Err(RegularFileLockError::NotRegular);
139 }
140 let file = fs::File::from(fd);
141 flock(&file, FlockOperation::LockExclusive).map_err(errno_to_lock_error)?;
142 Ok(file)
143 }
144
145 #[cfg(windows)]
146 {
147 Err(RegularFileLockError::UnsupportedPlatform)
148 }
149}
150
151#[cfg(not(windows))]
152fn errno_to_lock_error(source: rustix::io::Errno) -> RegularFileLockError {
153 RegularFileLockError::Io(io::Error::from_raw_os_error(source.raw_os_error()))
154}
155
156fn commit_bytes(path: &Path, bytes: &[u8], mode: FileCommitMode) -> io::Result<()> {
157 #[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
158 {
159 supported::commit_with_hook(path, bytes, mode, |_, _| Ok(()))
160 }
161
162 #[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple")))]
163 {
164 let _ = (path, bytes, mode);
165 Err(io::Error::new(
166 io::ErrorKind::Unsupported,
167 format!(
168 "durable atomic file publication is unsupported on platform {}",
169 std::env::consts::OS
170 ),
171 ))
172 }
173}
174
175#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
176mod supported {
177 use super::{BoundedRegularFileReadError, FileCommitMode, RegularFileReadError};
178
179 use std::{
180 ffi::{OsStr, OsString},
181 fs,
182 io::{self, Read, Write},
183 path::{Path, PathBuf},
184 sync::atomic::{AtomicU64, Ordering},
185 };
186
187 use rustix::{
188 fd::{AsFd, OwnedFd},
189 fs::{self as unix_fs, AtFlags, Mode, OFlags, RenameFlags},
190 };
191
192 const TEMP_ATTEMPTS: usize = 64;
193 static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
194
195 pub(super) fn read_optional_regular_bytes(
196 path: &Path,
197 ) -> Result<Option<Vec<u8>>, RegularFileReadError> {
198 let Some((mut file, _)) = open_optional_regular_file(path)? else {
199 return Ok(None);
200 };
201 let mut bytes = Vec::new();
202 file.read_to_end(&mut bytes)
203 .map_err(RegularFileReadError::Io)?;
204 Ok(Some(bytes))
205 }
206
207 pub(super) fn read_optional_regular_bytes_bounded(
208 path: &Path,
209 maximum_bytes: usize,
210 ) -> Result<Option<Vec<u8>>, BoundedRegularFileReadError> {
211 read_optional_regular_bytes_bounded_with_hook(path, maximum_bytes, || Ok(()))
212 }
213
214 #[cfg(test)]
215 pub(super) fn read_optional_regular_bytes_bounded_with_hook(
216 path: &Path,
217 maximum_bytes: usize,
218 before_read: impl FnOnce() -> io::Result<()>,
219 ) -> Result<Option<Vec<u8>>, BoundedRegularFileReadError> {
220 read_optional_regular_bytes_bounded_inner(path, maximum_bytes, before_read)
221 }
222
223 #[cfg(not(test))]
224 fn read_optional_regular_bytes_bounded_with_hook(
225 path: &Path,
226 maximum_bytes: usize,
227 before_read: impl FnOnce() -> io::Result<()>,
228 ) -> Result<Option<Vec<u8>>, BoundedRegularFileReadError> {
229 read_optional_regular_bytes_bounded_inner(path, maximum_bytes, before_read)
230 }
231
232 fn read_optional_regular_bytes_bounded_inner(
233 path: &Path,
234 maximum_bytes: usize,
235 before_read: impl FnOnce() -> io::Result<()>,
236 ) -> Result<Option<Vec<u8>>, BoundedRegularFileReadError> {
237 let Some((mut file, observed_size)) =
238 open_optional_regular_file(path).map_err(BoundedRegularFileReadError::Read)?
239 else {
240 return Ok(None);
241 };
242 if observed_size > u64::try_from(maximum_bytes).unwrap_or(u64::MAX) {
243 return Err(BoundedRegularFileReadError::TooLarge);
244 }
245 before_read().map_err(|source| {
246 BoundedRegularFileReadError::Read(RegularFileReadError::Io(source))
247 })?;
248
249 let read_limit = maximum_bytes
250 .checked_add(1)
251 .ok_or(BoundedRegularFileReadError::TooLarge)?;
252 let mut bytes = Vec::with_capacity(
253 usize::try_from(observed_size)
254 .unwrap_or(maximum_bytes)
255 .min(maximum_bytes),
256 );
257 Read::by_ref(&mut file)
258 .take(u64::try_from(read_limit).unwrap_or(u64::MAX))
259 .read_to_end(&mut bytes)
260 .map_err(RegularFileReadError::Io)
261 .map_err(BoundedRegularFileReadError::Read)?;
262 if bytes.len() > maximum_bytes {
263 return Err(BoundedRegularFileReadError::TooLarge);
264 }
265 Ok(Some(bytes))
266 }
267
268 fn open_optional_regular_file(
269 path: &Path,
270 ) -> Result<Option<(fs::File, u64)>, RegularFileReadError> {
271 let metadata = match fs::symlink_metadata(path) {
272 Ok(metadata) => metadata,
273 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
274 Err(error) => return Err(RegularFileReadError::Io(error)),
275 };
276 if !metadata.file_type().is_file() {
277 return Err(RegularFileReadError::NotRegular);
278 }
279
280 let fd: OwnedFd = unix_fs::open(
281 path,
282 OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
283 Mode::empty(),
284 )
285 .map_err(errno_to_io)
286 .map_err(RegularFileReadError::Io)?;
287 let metadata = unix_fs::fstat(&fd)
288 .map_err(errno_to_io)
289 .map_err(RegularFileReadError::Io)?;
290 if unix_fs::FileType::from_raw_mode(metadata.st_mode) != unix_fs::FileType::RegularFile {
291 return Err(RegularFileReadError::NotRegular);
292 }
293
294 let size = u64::try_from(metadata.st_size).map_err(|_| {
295 RegularFileReadError::Io(io::Error::new(
296 io::ErrorKind::InvalidData,
297 "regular file has a negative size",
298 ))
299 })?;
300 Ok(Some((fs::File::from(fd), size)))
301 }
302
303 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
304 pub(super) enum FileCommitStep {
305 ParentDirectoryCreate,
306 CreatedDirectorySync,
307 CreatedDirectoryParentSync,
308 TemporaryFileCreate,
309 TemporaryFileWrite,
310 TemporaryFileSync,
311 Publication,
312 FinalParentSync,
313 }
314
315 pub(super) fn commit_with_hook(
316 path: &Path,
317 bytes: &[u8],
318 mode: FileCommitMode,
319 mut before: impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
320 ) -> io::Result<()> {
321 let (parent, file_name) = split_target(path)?;
322 if matches!(
323 mode,
324 FileCommitMode::Replace | FileCommitMode::CreateNewWithParents
325 ) {
326 create_parent_hierarchy(parent, &mut before)?;
327 }
328 let parent_fd = open_directory(parent)?;
329 let (temp_name, temp_path, mut temp_file) =
330 create_sibling_temp(&parent_fd, parent, file_name, &mut before)?;
331
332 let staged = (|| {
333 before(FileCommitStep::TemporaryFileWrite, &temp_path)?;
334 temp_file.write_all(bytes)?;
335 before(FileCommitStep::TemporaryFileSync, &temp_path)?;
336 temp_file.sync_all()
337 })();
338 drop(temp_file);
339 if let Err(error) = staged {
340 remove_temp(&parent_fd, &temp_name);
341 return Err(error);
342 }
343
344 if let Err(error) = before(FileCommitStep::Publication, path) {
345 remove_temp(&parent_fd, &temp_name);
346 return Err(error);
347 }
348 let published = match mode {
349 FileCommitMode::Replace => {
350 unix_fs::renameat(&parent_fd, &temp_name, &parent_fd, file_name)
351 }
352 FileCommitMode::CreateNew | FileCommitMode::CreateNewWithParents => {
353 publish_create_new(&parent_fd, &temp_name, file_name)
354 }
355 };
356 if let Err(error) = published {
357 remove_temp(&parent_fd, &temp_name);
358 return Err(errno_to_io(error));
359 }
360
361 before(FileCommitStep::FinalParentSync, parent)?;
362 unix_fs::fsync(&parent_fd).map_err(errno_to_io)
363 }
364
365 fn publish_create_new(
366 parent_fd: &OwnedFd,
367 temp_name: &OsStr,
368 file_name: &OsStr,
369 ) -> rustix::io::Result<()> {
370 let renamed = unix_fs::renameat_with(
371 parent_fd,
372 temp_name,
373 parent_fd,
374 file_name,
375 RenameFlags::NOREPLACE,
376 );
377 finish_create_new_publication(parent_fd, temp_name, file_name, renamed)
378 }
379
380 fn finish_create_new_publication(
381 parent_fd: &OwnedFd,
382 temp_name: &OsStr,
383 file_name: &OsStr,
384 renamed: rustix::io::Result<()>,
385 ) -> rustix::io::Result<()> {
386 match renamed {
387 Ok(()) => Ok(()),
388 Err(
389 rustix::io::Errno::INVAL | rustix::io::Errno::NOSYS | rustix::io::Errno::OPNOTSUPP,
390 ) => {
391 unix_fs::linkat(parent_fd, temp_name, parent_fd, file_name, AtFlags::empty())?;
392 remove_temp(parent_fd, temp_name);
393 Ok(())
394 }
395 Err(error) => Err(error),
396 }
397 }
398
399 #[cfg(test)]
400 pub(super) fn publish_create_new_after_error(
401 parent: &Path,
402 temp_name: &OsStr,
403 file_name: &OsStr,
404 error: rustix::io::Errno,
405 ) -> io::Result<()> {
406 let parent_fd = open_directory(parent)?;
407 finish_create_new_publication(&parent_fd, temp_name, file_name, Err(error))
408 .map_err(errno_to_io)
409 }
410
411 fn split_target(path: &Path) -> io::Result<(&Path, &OsStr)> {
412 let file_name = path.file_name().ok_or_else(|| {
413 io::Error::new(
414 io::ErrorKind::InvalidInput,
415 format!("durable write target has no file name: {}", path.display()),
416 )
417 })?;
418 let parent = path
419 .parent()
420 .filter(|parent| !parent.as_os_str().is_empty())
421 .unwrap_or_else(|| Path::new("."));
422 Ok((parent, file_name))
423 }
424
425 fn create_parent_hierarchy(
426 parent: &Path,
427 before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
428 ) -> io::Result<()> {
429 let mut missing = Vec::new();
430 let mut current = parent;
431 loop {
432 match fs::symlink_metadata(current) {
433 Ok(metadata) if metadata.is_dir() => break,
434 Ok(_) => {
435 return Err(io::Error::new(
436 io::ErrorKind::NotADirectory,
437 format!("output parent is not a directory: {}", current.display()),
438 ));
439 }
440 Err(error) if error.kind() == io::ErrorKind::NotFound => {
441 missing.push(current.to_path_buf());
442 current = current
443 .parent()
444 .filter(|ancestor| !ancestor.as_os_str().is_empty())
445 .unwrap_or_else(|| Path::new("."));
446 }
447 Err(error) => return Err(error),
448 }
449 }
450
451 for directory in missing.into_iter().rev() {
452 before(FileCommitStep::ParentDirectoryCreate, &directory)?;
453 match fs::create_dir(&directory) {
454 Ok(()) => sync_created_directory(&directory, before)?,
455 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
456 if !fs::symlink_metadata(&directory)?.is_dir() {
457 return Err(io::Error::new(
458 io::ErrorKind::NotADirectory,
459 format!("output parent is not a directory: {}", directory.display()),
460 ));
461 }
462 }
463 Err(error) => return Err(error),
464 }
465 }
466 Ok(())
467 }
468
469 fn sync_created_directory(
470 directory: &Path,
471 before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
472 ) -> io::Result<()> {
473 before(FileCommitStep::CreatedDirectorySync, directory)?;
474 let directory_fd = open_directory(directory)?;
475 unix_fs::fsync(&directory_fd).map_err(errno_to_io)?;
476
477 let owner = directory
478 .parent()
479 .filter(|parent| !parent.as_os_str().is_empty())
480 .unwrap_or_else(|| Path::new("."));
481 before(FileCommitStep::CreatedDirectoryParentSync, owner)?;
482 let owner_fd = open_directory(owner)?;
483 unix_fs::fsync(&owner_fd).map_err(errno_to_io)
484 }
485
486 fn create_sibling_temp(
487 parent_fd: &impl AsFd,
488 parent: &Path,
489 file_name: &OsStr,
490 before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
491 ) -> io::Result<(OsString, PathBuf, fs::File)> {
492 for _ in 0..TEMP_ATTEMPTS {
493 let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
494 let mut temp_name = OsString::from(".");
495 temp_name.push(file_name);
496 temp_name.push(format!(".canic-tmp-{}-{sequence}", std::process::id()));
497 let temp_path = parent.join(&temp_name);
498 before(FileCommitStep::TemporaryFileCreate, &temp_path)?;
499 match unix_fs::openat(
500 parent_fd,
501 &temp_name,
502 OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC,
503 Mode::from_raw_mode(0o666),
504 ) {
505 Ok(file) => return Ok((temp_name, temp_path, fs::File::from(file))),
506 Err(error) if error == rustix::io::Errno::EXIST => {}
507 Err(error) => return Err(errno_to_io(error)),
508 }
509 }
510
511 Err(io::Error::new(
512 io::ErrorKind::AlreadyExists,
513 format!(
514 "could not allocate a unique sibling temporary file for {}",
515 parent.join(file_name).display()
516 ),
517 ))
518 }
519
520 fn open_directory(path: &Path) -> io::Result<OwnedFd> {
521 unix_fs::open(
522 path,
523 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
524 Mode::empty(),
525 )
526 .map_err(errno_to_io)
527 }
528
529 fn remove_temp(parent_fd: &impl AsFd, temp_name: &OsStr) {
530 let _ = unix_fs::unlinkat(parent_fd, temp_name, AtFlags::empty());
531 }
532
533 fn errno_to_io(error: rustix::io::Errno) -> io::Error {
534 io::Error::from_raw_os_error(error.raw_os_error())
535 }
536}