1#![allow(clippy::disallowed_methods)]
10
11use std::io::Write as _;
12use std::path::{Path, PathBuf};
13
14#[cfg(any(test, feature = "test-utils"))]
15use std::sync::atomic::{AtomicUsize, Ordering};
16
17#[derive(Clone, Debug)]
18pub(crate) enum FileSync {
19 Enabled,
20 Disabled,
21 #[cfg(any(test, feature = "test-utils"))]
22 ObservedDisabled(std::sync::Arc<AtomicUsize>),
23}
24
25impl FileSync {
26 fn requested(&self) {
27 #[cfg(any(test, feature = "test-utils"))]
28 if let Self::ObservedDisabled(requests) = self {
29 requests.fetch_add(1, Ordering::SeqCst);
30 }
31 }
32
33 pub(crate) async fn finish_async_write(
34 &self,
35 file: &mut tokio::fs::File,
36 ) -> std::io::Result<()> {
37 use tokio::io::AsyncWriteExt;
38
39 file.flush().await?;
40 self.sync_file(file).await
41 }
42
43 async fn sync_file(&self, file: &tokio::fs::File) -> std::io::Result<()> {
44 self.requested();
45 match self {
46 Self::Enabled => file.sync_all().await,
47 Self::Disabled => Ok(()),
48 #[cfg(any(test, feature = "test-utils"))]
49 Self::ObservedDisabled(_) => Ok(()),
50 }
51 }
52
53 pub(crate) fn sync_file_blocking(&self, file: &std::fs::File) -> std::io::Result<()> {
54 self.requested();
55 match self {
56 Self::Enabled => file.sync_all(),
57 Self::Disabled => Ok(()),
58 #[cfg(any(test, feature = "test-utils"))]
59 Self::ObservedDisabled(_) => Ok(()),
60 }
61 }
62
63 pub(crate) async fn sync_parent(&self, path: &Path) -> Result<(), FileError> {
64 let parent = parent_of(path)?;
65 self.requested();
66 match self {
67 Self::Enabled => flush_directory(parent)
68 .await
69 .map_err(|source| FileError::at("fsync parent directory", parent, source)),
70 Self::Disabled => Ok(()),
71 #[cfg(any(test, feature = "test-utils"))]
72 Self::ObservedDisabled(_) => Ok(()),
73 }
74 }
75
76 pub(crate) fn sync_parent_blocking(&self, path: &Path) -> Result<(), FileError> {
77 let parent = parent_of(path)?;
78 self.requested();
79 match self {
80 Self::Enabled => flush_directory_blocking(parent)
81 .map_err(|source| FileError::at("fsync parent directory", parent, source)),
82 Self::Disabled => Ok(()),
83 #[cfg(any(test, feature = "test-utils"))]
84 Self::ObservedDisabled(_) => Ok(()),
85 }
86 }
87}
88
89#[derive(Debug, thiserror::Error)]
91pub enum FileError {
92 #[error("{operation} {}: {source}", path.display())]
93 Path {
94 operation: &'static str,
95 path: PathBuf,
96 #[source]
97 source: std::io::Error,
98 },
99 #[error("{subject} is not a file: {}", path.display())]
100 NotFile {
101 subject: &'static str,
102 path: PathBuf,
103 },
104 #[error("{operation} {} -> {}: {source}", from.display(), to.display())]
105 BetweenPaths {
106 operation: &'static str,
107 from: PathBuf,
108 to: PathBuf,
109 #[source]
110 source: std::io::Error,
111 },
112 #[error("path has no parent directory: {}", path.display())]
113 NoParent { path: PathBuf },
114 #[error("atomic stage in {} cannot commit to {}", stage_parent.display(), destination.display())]
115 InvalidAtomicDestination {
116 stage_parent: PathBuf,
117 destination: PathBuf,
118 },
119 #[error("{subject} size overflow: {}", path.display())]
120 SizeOverflow {
121 subject: &'static str,
122 path: PathBuf,
123 },
124 #[error("local file range is too large: {len} bytes")]
125 RangeTooLarge { len: u64 },
126 #[error("file modification time for {} predates the Unix epoch: {source}", path.display())]
127 ModifiedBeforeUnixEpoch {
128 path: PathBuf,
129 #[source]
130 source: std::time::SystemTimeError,
131 },
132 #[error("atomic write {}: {source}", path.display())]
133 AtomicWrite {
134 path: PathBuf,
135 #[source]
136 source: WriteError<std::io::Error>,
137 },
138 #[error("{operation}; rollback failed: {rollback}")]
139 RollbackFailed {
140 operation: Box<FileError>,
141 rollback: Box<FileError>,
142 },
143}
144
145impl FileError {
146 pub fn at(operation: &'static str, path: impl Into<PathBuf>, source: std::io::Error) -> Self {
147 Self::Path {
148 operation,
149 path: path.into(),
150 source,
151 }
152 }
153
154 pub fn between(
155 operation: &'static str,
156 from: impl Into<PathBuf>,
157 to: impl Into<PathBuf>,
158 source: std::io::Error,
159 ) -> Self {
160 Self::BetweenPaths {
161 operation,
162 from: from.into(),
163 to: to.into(),
164 source,
165 }
166 }
167
168 pub fn rollback(operation: FileError, rollback: FileError) -> Self {
169 Self::RollbackFailed {
170 operation: Box::new(operation),
171 rollback: Box::new(rollback),
172 }
173 }
174}
175
176pub(crate) const TEMP_FILE_PREFIX: &str = ".tmp.";
177
178pub fn is_atomic_staging_file_name(name: &std::ffi::OsStr) -> bool {
182 name.to_string_lossy().starts_with(TEMP_FILE_PREFIX)
183}
184
185#[derive(Debug)]
193pub enum WriteError<E> {
194 BeforeCommit(E),
196 AfterCommit(E),
199}
200
201impl<E> WriteError<E> {
202 pub fn committed(&self) -> bool {
204 matches!(self, Self::AfterCommit(_))
205 }
206
207 pub fn into_inner(self) -> E {
208 match self {
209 Self::BeforeCommit(error) | Self::AfterCommit(error) => error,
210 }
211 }
212
213 pub fn map<F>(self, convert: impl FnOnce(E) -> F) -> WriteError<F> {
215 match self {
216 Self::BeforeCommit(error) => WriteError::BeforeCommit(convert(error)),
217 Self::AfterCommit(error) => WriteError::AfterCommit(convert(error)),
218 }
219 }
220}
221
222impl<E: std::fmt::Display> std::fmt::Display for WriteError<E> {
223 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224 match self {
225 Self::BeforeCommit(error) => write!(f, "{error}"),
226 Self::AfterCommit(error) => write!(f, "{error} (after the write committed)"),
227 }
228 }
229}
230
231impl<E: std::error::Error + 'static> std::error::Error for WriteError<E> {
232 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
233 match self {
234 Self::BeforeCommit(error) | Self::AfterCommit(error) => Some(error),
235 }
236 }
237}
238
239pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), WriteError<std::io::Error>> {
245 let parent = path
246 .parent()
247 .filter(|parent| !parent.as_os_str().is_empty())
248 .unwrap_or_else(|| Path::new("."));
249
250 let mut temp = tempfile::Builder::new()
251 .prefix(TEMP_FILE_PREFIX)
252 .tempfile_in(parent)
253 .map_err(WriteError::BeforeCommit)?;
254 temp.write_all(bytes).map_err(WriteError::BeforeCommit)?;
255 temp.as_file()
256 .sync_all()
257 .map_err(WriteError::BeforeCommit)?;
258 temp.persist(path)
260 .map_err(|error| WriteError::BeforeCommit(error.error))?;
261 flush_directory_blocking(parent).map_err(WriteError::AfterCommit)
262}
263
264pub struct AtomicFileStage {
270 parent: PathBuf,
271 temp: tempfile::NamedTempFile,
272 file_sync: FileSync,
273}
274
275impl AtomicFileStage {
276 pub fn create_in(parent: &Path) -> Result<Self, std::io::Error> {
277 Self::create_in_with_file_sync(parent, FileSync::Enabled)
278 }
279
280 pub(crate) fn create_in_with_file_sync(
281 parent: &Path,
282 file_sync: FileSync,
283 ) -> Result<Self, std::io::Error> {
284 std::fs::create_dir_all(parent)?;
285 let temp = tempfile::Builder::new()
286 .prefix(TEMP_FILE_PREFIX)
287 .tempfile_in(parent)?;
288 Ok(Self {
289 parent: parent.to_path_buf(),
290 temp,
291 file_sync,
292 })
293 }
294
295 pub fn commit(self, destination: &Path) -> Result<(), WriteError<FileError>> {
296 if destination.parent() != Some(self.parent.as_path()) {
297 return Err(WriteError::BeforeCommit(
298 FileError::InvalidAtomicDestination {
299 stage_parent: self.parent,
300 destination: destination.to_path_buf(),
301 },
302 ));
303 }
304 let staged_path = self.temp.path().to_path_buf();
305 self.file_sync
306 .sync_file_blocking(self.temp.as_file())
307 .map_err(|source| {
308 WriteError::BeforeCommit(FileError::at("sync staged file", &staged_path, source))
309 })?;
310 self.temp.persist(destination).map_err(|error| {
311 WriteError::BeforeCommit(FileError::between(
312 "persist atomic stage",
313 &staged_path,
314 destination,
315 error.error,
316 ))
317 })?;
318 self.file_sync
319 .sync_parent_blocking(destination)
320 .map_err(WriteError::AfterCommit)
321 }
322}
323
324impl std::io::Write for AtomicFileStage {
325 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
326 self.temp.write(bytes)
327 }
328
329 fn flush(&mut self) -> std::io::Result<()> {
330 self.temp.flush()
331 }
332}
333
334pub struct AtomicFile {
336 path: PathBuf,
337}
338
339impl AtomicFile {
340 pub fn new(path: PathBuf) -> Self {
341 Self { path }
342 }
343
344 pub fn read_optional(&self) -> Result<Option<Vec<u8>>, FileError> {
345 match std::fs::read(&self.path) {
346 Ok(bytes) => Ok(Some(bytes)),
347 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
348 Err(source) => Err(FileError::at("read file", &self.path, source)),
349 }
350 }
351
352 pub fn replace(&self, bytes: &[u8]) -> Result<(), FileError> {
353 let parent = parent_of(&self.path)?;
354 std::fs::create_dir_all(parent)
355 .map_err(|source| FileError::at("create parent directory", parent, source))?;
356 write_atomic(&self.path, bytes).map_err(|source| FileError::AtomicWrite {
357 path: self.path.clone(),
358 source,
359 })
360 }
361
362 pub fn remove(&self) -> Result<(), FileError> {
363 match std::fs::remove_file(&self.path) {
364 Ok(()) => Ok(()),
365 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
366 Err(source) => Err(FileError::at("remove file", &self.path, source)),
367 }
368 }
369
370 #[cfg(any(test, feature = "test-utils"))]
371 pub fn path(&self) -> &Path {
372 &self.path
373 }
374}
375
376fn parent_of(path: &Path) -> Result<&Path, FileError> {
378 path.parent().ok_or_else(|| FileError::NoParent {
379 path: path.to_path_buf(),
380 })
381}
382
383pub async fn sync_parent_dir(path: &Path) -> Result<(), FileError> {
386 let parent = parent_of(path)?;
387 flush_directory(parent)
388 .await
389 .map_err(|source| FileError::at("fsync parent directory", parent, source))
390}
391
392pub fn sync_parent_dir_blocking(path: &Path) -> Result<(), FileError> {
394 let parent = parent_of(path)?;
395 flush_directory_blocking(parent)
396 .map_err(|source| FileError::at("fsync parent directory", parent, source))
397}
398
399#[cfg(unix)]
400async fn flush_directory(directory: &Path) -> std::io::Result<()> {
401 tokio::fs::File::open(directory).await?.sync_all().await
402}
403
404#[cfg(unix)]
405fn flush_directory_blocking(directory: &Path) -> std::io::Result<()> {
406 std::fs::File::open(directory)?.sync_all()
407}
408
409#[cfg(not(unix))]
417async fn flush_directory(_directory: &Path) -> std::io::Result<()> {
418 Ok(())
419}
420
421#[cfg(not(unix))]
422fn flush_directory_blocking(_directory: &Path) -> std::io::Result<()> {
423 Ok(())
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 #[test]
431 fn write_atomic_replaces_existing_contents() {
432 let directory = tempfile::tempdir().expect("temporary directory");
433 let path = directory.path().join("config.yaml");
434 std::fs::write(&path, b"old").expect("seed");
435
436 write_atomic(&path, b"new").expect("atomic write");
437
438 assert_eq!(std::fs::read(&path).expect("read"), b"new");
439 }
440
441 #[test]
442 fn write_atomic_leaves_no_temporary_sibling() {
443 let directory = tempfile::tempdir().expect("temporary directory");
444
445 write_atomic(&directory.path().join("config.yaml"), b"bytes").expect("atomic write");
446
447 let leftovers: Vec<_> = std::fs::read_dir(directory.path())
448 .expect("read dir")
449 .map(|entry| entry.expect("entry").file_name())
450 .filter(|name| name.to_string_lossy().starts_with(TEMP_FILE_PREFIX))
451 .collect();
452 assert!(leftovers.is_empty(), "{leftovers:?}");
453 }
454
455 #[test]
456 fn write_atomic_requires_an_existing_parent() {
457 let directory = tempfile::tempdir().expect("temporary directory");
458 let path = directory.path().join("missing").join("config.yaml");
459
460 let error = write_atomic(&path, b"bytes").expect_err("absent parent");
461
462 assert!(!error.committed());
463 assert_eq!(error.into_inner().kind(), std::io::ErrorKind::NotFound);
464 assert!(!path.exists());
465 }
466
467 #[test]
468 fn streaming_stage_commits_only_the_complete_file() {
469 let directory = tempfile::tempdir().expect("temporary directory");
470 let path = directory.path().join("payload");
471 let mut stage = AtomicFileStage::create_in(directory.path()).expect("create stage");
472
473 stage.write_all(b"first ").expect("write first part");
474 stage.write_all(b"second").expect("write second part");
475 assert!(!path.exists());
476 stage.commit(&path).expect("commit stage");
477
478 assert_eq!(std::fs::read(path).expect("read payload"), b"first second");
479 }
480
481 #[test]
484 fn write_atomic_commits_without_a_posix_directory_fsync() {
485 let directory = tempfile::tempdir().expect("temporary directory");
486 let path = directory.path().join("installed");
487
488 write_atomic(&path, b"bytes").expect("atomic write");
489 sync_parent_dir_blocking(&path).expect("sync parent directory");
490
491 assert_eq!(std::fs::read(&path).expect("read"), b"bytes");
492 }
493
494 #[tokio::test]
495 async fn sync_parent_dir_accepts_an_installed_file() {
496 let directory = tempfile::tempdir().expect("temporary directory");
497 let path = directory.path().join("installed");
498 tokio::fs::write(&path, b"bytes").await.expect("write");
499
500 sync_parent_dir(&path).await.expect("sync parent directory");
501 }
502
503 #[tokio::test]
504 async fn sync_parent_dir_rejects_a_path_with_no_parent() {
505 let error = sync_parent_dir(Path::new("/"))
506 .await
507 .expect_err("root has no parent");
508
509 assert!(matches!(
510 error,
511 FileError::NoParent { path } if path == Path::new("/")
512 ));
513 }
514
515 #[test]
516 fn atomic_file_round_trips_through_a_created_parent() {
517 let directory = tempfile::tempdir().expect("temporary directory");
518 let file = AtomicFile::new(directory.path().join("nested").join("config.yaml"));
519
520 assert_eq!(file.read_optional().expect("absent read"), None);
521 file.replace(b"first").expect("install");
522 file.replace(b"second").expect("replace");
523
524 assert_eq!(
525 file.read_optional().expect("read"),
526 Some(b"second".to_vec())
527 );
528 }
529}