Skip to main content

rskit_fs/async_io/
file.rs

1//! Async file helpers.
2#![allow(clippy::needless_pass_by_value)]
3
4use std::path::Path;
5
6use rskit_errors::{AppError, AppResult, ErrorCode};
7use tokio::io::{AsyncReadExt, AsyncWriteExt};
8
9use crate::file_error::file_too_large_error;
10pub use crate::file_error::{
11    is_file_too_large_error, is_not_regular_file_error, is_symlink_not_allowed_error,
12};
13use crate::path::parent_dir;
14use crate::temp::sibling_temp_path;
15use crate::types::FileMeta;
16
17const WRITE_ATOMIC_TEMP_ATTEMPTS: usize = 16;
18
19/// Async file handle opened through this crate.
20pub type AsyncFile = tokio::fs::File;
21
22/// Create the parent directory for a file path if it has one.
23pub async fn create_parent_dir(path: &Path) -> AppResult<()> {
24    if let Some(parent) = parent_dir(path) {
25        super::dir::create_all(parent).await?;
26    }
27    Ok(())
28}
29
30/// Return true when `path` exists as a regular file, without following symlinks.
31pub async fn exists(path: &Path) -> AppResult<bool> {
32    exists_from_metadata(path, tokio::fs::symlink_metadata(path).await)
33}
34
35fn exists_from_metadata(
36    path: &Path,
37    result: std::io::Result<std::fs::Metadata>,
38) -> AppResult<bool> {
39    match result {
40        Ok(metadata) => Ok(metadata.is_file() && !metadata.file_type().is_symlink()),
41        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
42        Err(error) => Err(inspect_file_error(path, error)),
43    }
44}
45
46/// Read file metadata without following symlinks.
47pub async fn metadata(path: &Path) -> AppResult<FileMeta> {
48    let metadata = tokio::fs::symlink_metadata(path)
49        .await
50        .map_err(|error| inspect_file_error(path, error))?;
51    Ok(FileMeta {
52        path: path.to_path_buf(),
53        len: metadata.len(),
54        created: metadata.created().ok(),
55        modified: metadata.modified().ok(),
56        is_file: metadata.is_file(),
57        is_dir: metadata.is_dir(),
58        is_symlink: metadata.file_type().is_symlink(),
59    })
60}
61
62/// Read a file into memory.
63pub async fn read(path: &Path) -> AppResult<Vec<u8>> {
64    tokio::fs::read(path)
65        .await
66        .map_err(|error| read_file_error(path, error))
67}
68
69/// Read a UTF-8 text file.
70pub async fn read_string(path: &Path) -> AppResult<String> {
71    tokio::fs::read_to_string(path)
72        .await
73        .map_err(|error| read_file_error(path, error))
74}
75
76/// Read at most `max_bytes` from a regular file without following a final symlink.
77pub async fn read_bounded(path: &Path, max_bytes: u64) -> AppResult<Vec<u8>> {
78    let file = open_no_follow_regular(path).await?;
79    let metadata = file
80        .metadata()
81        .await
82        .map_err(|error| inspect_file_error(path, error))?;
83    if metadata.is_file() && metadata.len() > max_bytes {
84        return Err(file_too_large_error(path, metadata.len(), max_bytes));
85    }
86
87    let capacity = metadata.len().min(max_bytes).try_into().unwrap_or(0);
88    let mut bytes = Vec::with_capacity(capacity);
89    file.take(max_bytes.saturating_add(1))
90        .read_to_end(&mut bytes)
91        .await
92        .map_err(|error| read_file_error(path, error))?;
93    if bytes.len() as u64 > max_bytes {
94        return Err(file_too_large_error(path, bytes.len() as u64, max_bytes));
95    }
96    Ok(bytes)
97}
98
99/// Read a UTF-8 text file up to `max_bytes` bytes without following a final symlink.
100pub async fn read_string_bounded(path: &Path, max_bytes: u64) -> AppResult<String> {
101    let bytes = read_bounded(path, max_bytes).await?;
102    String::from_utf8(bytes).map_err(|error| {
103        AppError::new(
104            ErrorCode::InvalidInput,
105            format!("file '{}' is not valid UTF-8: {error}", path.display()),
106        )
107    })
108}
109
110/// Write bytes to a file, creating parent directories as needed.
111pub async fn write(path: &Path, bytes: impl AsRef<[u8]>) -> AppResult<()> {
112    create_parent_dir(path).await?;
113    tokio::fs::write(path, bytes)
114        .await
115        .map_err(|error| write_file_error(path, error))
116}
117
118/// Open a file for async reading.
119pub async fn open(path: &Path) -> AppResult<AsyncFile> {
120    tokio::fs::File::open(path)
121        .await
122        .map_err(|error| open_file_error(path, error))
123}
124
125/// Open a regular file for async reads without following a final symlink.
126pub async fn open_no_follow_regular(path: &Path) -> AppResult<AsyncFile> {
127    let path = path.to_path_buf();
128    let file = tokio::task::spawn_blocking({
129        let path = path.clone();
130        move || crate::sync_io::file::open_no_follow_regular(&path)
131    })
132    .await
133    .map_err(|error| {
134        AppError::new(
135            ErrorCode::Internal,
136            format!(
137                "failed to join no-follow file open task for '{}': {error}",
138                path.display()
139            ),
140        )
141        .with_cause(error)
142    })??;
143
144    Ok(AsyncFile::from_std(file))
145}
146
147/// Create a file for async writing, creating parent directories as needed.
148pub async fn create(path: &Path) -> AppResult<AsyncFile> {
149    create_parent_dir(path).await?;
150    tokio::fs::File::create(path)
151        .await
152        .map_err(|error| create_file_error(path, error))
153}
154
155/// Persist a temp file to `dest` using the platform rename operation.
156///
157/// Replacing an existing destination is atomic on Unix-like platforms. On
158/// Windows, replacing an existing destination is not supported by this helper
159/// because `rename` fails when `dest` already exists.
160pub async fn persist_temp_file(temp_path: &Path, dest: &Path) -> AppResult<()> {
161    tokio::fs::rename(temp_path, dest)
162        .await
163        .map_err(|error| rename_file_error(temp_path, dest, error))
164}
165
166/// Copy one file to another path, creating parent directories as needed.
167pub async fn copy(from: &Path, to: &Path) -> AppResult<u64> {
168    create_parent_dir(to).await?;
169    tokio::fs::copy(from, to)
170        .await
171        .map_err(|error| copy_file_error(from, to, error))
172}
173
174/// Rename or move a file, creating the destination parent directory as needed.
175pub async fn rename(from: &Path, to: &Path) -> AppResult<()> {
176    create_parent_dir(to).await?;
177    tokio::fs::rename(from, to)
178        .await
179        .map_err(|error| rename_file_error(from, to, error))
180}
181
182/// Move a file, falling back to copy+delete when a platform rename cannot cross filesystems.
183///
184/// This fallback is not atomic across filesystems. Use [`rename`] when atomic
185/// same-filesystem replacement is required.
186pub async fn move_file(from: &Path, to: &Path) -> AppResult<()> {
187    create_parent_dir(to).await?;
188    move_file_after_rename(from, to, tokio::fs::rename(from, to).await).await
189}
190
191async fn move_file_after_rename(
192    from: &Path,
193    to: &Path,
194    result: std::io::Result<()>,
195) -> AppResult<()> {
196    match result {
197        Ok(()) => Ok(()),
198        Err(error) if is_cross_device_error(&error) => {
199            copy(from, to).await?;
200            remove(from).await
201        }
202        Err(error) => Err(move_file_error(from, to, error)),
203    }
204}
205
206/// Remove a file.
207pub async fn remove(path: &Path) -> AppResult<()> {
208    tokio::fs::remove_file(path)
209        .await
210        .map_err(|error| remove_file_error(path, error))
211}
212
213fn is_cross_device_error(error: &std::io::Error) -> bool {
214    #[cfg(unix)]
215    {
216        error.raw_os_error() == Some(libc::EXDEV)
217    }
218    #[cfg(not(unix))]
219    {
220        error.kind() == std::io::ErrorKind::CrossesDevices
221    }
222}
223
224/// Remove a file and ignore `NotFound`.
225pub async fn remove_if_exists(path: &Path) -> AppResult<bool> {
226    match tokio::fs::remove_file(path).await {
227        Ok(()) => Ok(true),
228        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
229        Err(error) => Err(remove_file_error(path, error)),
230    }
231}
232
233/// Atomically write bytes by writing a sibling temp file and renaming it.
234///
235/// Replacing an existing destination is atomic on Unix-like platforms. On
236/// Windows, this helper succeeds for new destinations and returns an error when
237/// replacing an existing destination.
238pub async fn write_atomic(
239    dest: &Path,
240    bytes: impl AsRef<[u8]>,
241    temp_prefix: &str,
242) -> AppResult<()> {
243    write_atomic_with_attempts(dest, bytes, temp_prefix, WRITE_ATOMIC_TEMP_ATTEMPTS, false).await
244}
245
246/// Atomically write bytes and replace an existing destination when supported.
247///
248/// Replacing an existing destination is atomic on Unix-like platforms. On
249/// Windows, this helper removes the existing file before persisting the temp
250/// file because the platform rename operation cannot replace an existing file.
251pub async fn write_atomic_replace(
252    dest: &Path,
253    bytes: impl AsRef<[u8]>,
254    temp_prefix: &str,
255) -> AppResult<()> {
256    write_atomic_with_attempts(dest, bytes, temp_prefix, WRITE_ATOMIC_TEMP_ATTEMPTS, true).await
257}
258
259async fn write_atomic_with_attempts(
260    dest: &Path,
261    bytes: impl AsRef<[u8]>,
262    temp_prefix: &str,
263    attempts: usize,
264    replace_existing: bool,
265) -> AppResult<()> {
266    create_parent_dir(dest).await?;
267
268    let bytes = bytes.as_ref();
269    for _ in 0..attempts {
270        let temp_path = sibling_temp_path(dest, temp_prefix, ".tmp");
271        let mut temp_file = match tokio::fs::OpenOptions::new()
272            .write(true)
273            .create_new(true)
274            .open(&temp_path)
275            .await
276        {
277            Ok(file) => file,
278            Err(error) if should_retry_temp_open(&error) => continue,
279            Err(error) => return Err(create_temp_file_error(&temp_path, error)),
280        };
281
282        let result = async {
283            temp_file
284                .write_all(bytes)
285                .await
286                .map_err(|error| write_temp_file_error(&temp_path, error))?;
287            temp_file
288                .sync_data()
289                .await
290                .map_err(|error| sync_temp_file_error(&temp_path, error))?;
291            drop(temp_file);
292            persist_temp_file_with_replace(&temp_path, dest, replace_existing).await
293        }
294        .await;
295
296        if result.is_err() {
297            let _ = remove_if_exists(&temp_path).await;
298        }
299
300        return result;
301    }
302
303    Err(unique_temp_file_error(dest, attempts))
304}
305
306async fn persist_temp_file_with_replace(
307    temp_path: &Path,
308    dest: &Path,
309    replace_existing: bool,
310) -> AppResult<()> {
311    #[cfg(windows)]
312    if replace_existing {
313        remove_if_exists(dest).await?;
314    }
315
316    let _ = replace_existing;
317    persist_temp_file(temp_path, dest).await
318}
319
320fn should_retry_temp_open(error: &std::io::Error) -> bool {
321    error.kind() == std::io::ErrorKind::AlreadyExists
322}
323
324fn inspect_file_error(path: &Path, error: std::io::Error) -> AppError {
325    AppError::new(
326        ErrorCode::Internal,
327        format!("failed to inspect file '{}': {error}", path.display()),
328    )
329    .with_cause(error)
330}
331
332fn read_file_error(path: &Path, error: std::io::Error) -> AppError {
333    AppError::new(
334        ErrorCode::Internal,
335        format!("failed to read file '{}': {error}", path.display()),
336    )
337    .with_cause(error)
338}
339
340fn open_file_error(path: &Path, error: std::io::Error) -> AppError {
341    AppError::new(
342        ErrorCode::Internal,
343        format!("failed to open file '{}': {error}", path.display()),
344    )
345    .with_cause(error)
346}
347
348fn create_file_error(path: &Path, error: std::io::Error) -> AppError {
349    AppError::new(
350        ErrorCode::Internal,
351        format!("failed to create file '{}': {error}", path.display()),
352    )
353    .with_cause(error)
354}
355
356fn write_file_error(path: &Path, error: std::io::Error) -> AppError {
357    AppError::new(
358        ErrorCode::Internal,
359        format!("failed to write file '{}': {error}", path.display()),
360    )
361    .with_cause(error)
362}
363
364fn copy_file_error(from: &Path, to: &Path, error: std::io::Error) -> AppError {
365    AppError::new(
366        ErrorCode::Internal,
367        format!(
368            "failed to copy '{}' to '{}': {error}",
369            from.display(),
370            to.display()
371        ),
372    )
373    .with_cause(error)
374}
375
376fn rename_file_error(from: &Path, to: &Path, error: std::io::Error) -> AppError {
377    AppError::new(
378        ErrorCode::Internal,
379        format!(
380            "failed to rename '{}' to '{}': {error}",
381            from.display(),
382            to.display()
383        ),
384    )
385    .with_cause(error)
386}
387
388fn move_file_error(from: &Path, to: &Path, error: std::io::Error) -> AppError {
389    AppError::new(
390        ErrorCode::Internal,
391        format!(
392            "failed to move '{}' to '{}': {error}",
393            from.display(),
394            to.display()
395        ),
396    )
397    .with_cause(error)
398}
399
400fn remove_file_error(path: &Path, error: std::io::Error) -> AppError {
401    AppError::new(
402        ErrorCode::Internal,
403        format!("failed to remove '{}': {error}", path.display()),
404    )
405    .with_cause(error)
406}
407
408fn create_temp_file_error(path: &Path, error: std::io::Error) -> AppError {
409    AppError::new(
410        ErrorCode::Internal,
411        format!("failed to create temp file '{}': {error}", path.display()),
412    )
413    .with_cause(error)
414}
415
416fn write_temp_file_error(path: &Path, error: std::io::Error) -> AppError {
417    AppError::new(
418        ErrorCode::Internal,
419        format!("failed to write temp file '{}': {error}", path.display()),
420    )
421    .with_cause(error)
422}
423
424fn sync_temp_file_error(path: &Path, error: std::io::Error) -> AppError {
425    AppError::new(
426        ErrorCode::Internal,
427        format!("failed to sync temp file '{}': {error}", path.display()),
428    )
429    .with_cause(error)
430}
431
432fn unique_temp_file_error(dest: &Path, attempts: usize) -> AppError {
433    AppError::new(
434        ErrorCode::Internal,
435        format!(
436            "failed to create a unique temp file for '{}' after {attempts} attempts",
437            dest.display()
438        ),
439    )
440}
441
442#[cfg(test)]
443mod tests {
444    use super::{
445        WRITE_ATOMIC_TEMP_ATTEMPTS, copy, copy_file_error, create_parent_dir,
446        create_temp_file_error, exists, exists_from_metadata, inspect_file_error,
447        is_file_too_large_error, is_not_regular_file_error, is_symlink_not_allowed_error, metadata,
448        move_file, move_file_after_rename, move_file_error, open_no_follow_regular,
449        persist_temp_file, persist_temp_file_with_replace, read, read_bounded, read_file_error,
450        read_string, read_string_bounded, remove, remove_file_error, remove_if_exists, rename,
451        rename_file_error, should_retry_temp_open, sync_temp_file_error, unique_temp_file_error,
452        write, write_atomic, write_atomic_replace, write_atomic_with_attempts, write_file_error,
453        write_temp_file_error,
454    };
455    use crate::TempDir;
456
457    #[tokio::test]
458    async fn file_lifecycle() {
459        let root = TempDir::new().unwrap();
460        let path = root.child("a/b.txt").unwrap();
461
462        write(&path, b"hello").await.unwrap();
463        assert!(exists(&path).await.unwrap());
464        assert_eq!(read(&path).await.unwrap(), b"hello");
465        assert_eq!(read_string(&path).await.unwrap(), "hello");
466        assert_eq!(metadata(&path).await.unwrap().len, 5);
467
468        let copy_path = root.child("copy/b.txt").unwrap();
469        assert_eq!(copy(&path, &copy_path).await.unwrap(), 5);
470        assert_eq!(read_string(&copy_path).await.unwrap(), "hello");
471
472        let renamed = root.child("renamed/b.txt").unwrap();
473        rename(&copy_path, &renamed).await.unwrap();
474        assert!(!exists(&copy_path).await.unwrap());
475        assert!(exists(&renamed).await.unwrap());
476        assert!(remove_if_exists(&renamed).await.unwrap());
477        assert!(!remove_if_exists(&renamed).await.unwrap());
478    }
479
480    #[tokio::test]
481    async fn create_parent_dir_ignores_paths_without_parent() {
482        create_parent_dir(std::path::Path::new("file.txt"))
483            .await
484            .unwrap();
485    }
486
487    #[tokio::test]
488    async fn file_error_paths_are_reported() {
489        let root = TempDir::new().unwrap();
490        let missing = root.child("missing.txt").unwrap();
491        let dir = root.child("dir").unwrap();
492        crate::async_io::dir::create_all(&dir).await.unwrap();
493        let nested_under_file = root.child("file.txt/child.txt").unwrap();
494        root.write_file("file.txt", b"hello").unwrap();
495
496        assert!(!exists(&missing).await.unwrap());
497        assert!(read(&missing).await.is_err());
498        assert!(read_string(&missing).await.is_err());
499        assert!(write(&nested_under_file, b"nope").await.is_err());
500        assert!(
501            copy(&missing, &root.child("copy.txt").unwrap())
502                .await
503                .is_err()
504        );
505        assert!(
506            rename(&missing, &root.child("renamed.txt").unwrap())
507                .await
508                .is_err()
509        );
510        assert!(remove(&missing).await.is_err());
511        assert!(remove_if_exists(&dir).await.is_err());
512    }
513
514    #[tokio::test]
515    async fn bounded_read_accepts_regular_files_within_limit() {
516        let root = TempDir::new().unwrap();
517        let path = root.write_file("file.txt", b"hello").unwrap();
518
519        assert_eq!(read_bounded(&path, 5).await.unwrap(), b"hello");
520        assert_eq!(read_string_bounded(&path, 5).await.unwrap(), "hello");
521    }
522
523    #[tokio::test]
524    async fn bounded_read_rejects_oversized_files() {
525        let root = TempDir::new().unwrap();
526        let path = root.write_file("file.txt", b"hello").unwrap();
527
528        let error = read_bounded(&path, 4).await.unwrap_err();
529
530        assert!(is_file_too_large_error(&error));
531    }
532
533    #[tokio::test]
534    async fn bounded_read_rejects_directories() {
535        let root = TempDir::new().unwrap();
536
537        let error = read_bounded(root.path(), 1024).await.unwrap_err();
538
539        assert!(is_not_regular_file_error(&error));
540    }
541
542    #[cfg(unix)]
543    #[tokio::test]
544    async fn bounded_read_rejects_final_symlinks() {
545        let root = TempDir::new().unwrap();
546        let target = root.write_file("target.txt", b"hello").unwrap();
547        let link = root.child("link.txt").unwrap();
548        std::os::unix::fs::symlink(&target, &link).unwrap();
549
550        let error = read_bounded(&link, 1024).await.unwrap_err();
551
552        assert!(is_symlink_not_allowed_error(&error));
553    }
554
555    #[tokio::test]
556    async fn no_follow_regular_open_accepts_regular_files() {
557        let root = TempDir::new().unwrap();
558        let path = root.write_file("file.txt", b"hello").unwrap();
559
560        let file = open_no_follow_regular(&path).await.unwrap();
561
562        assert!(file.metadata().await.unwrap().is_file());
563    }
564
565    #[test]
566    fn file_error_builders_include_context() {
567        let from = std::path::Path::new("from.txt");
568        let to = std::path::Path::new("to.txt");
569        let err = || std::io::Error::other("boom");
570
571        assert!(
572            inspect_file_error(from, err())
573                .to_string()
574                .contains("inspect file")
575        );
576        assert!(
577            read_file_error(from, err())
578                .to_string()
579                .contains("read file")
580        );
581        assert!(
582            write_file_error(from, err())
583                .to_string()
584                .contains("write file")
585        );
586        assert!(
587            copy_file_error(from, to, err())
588                .to_string()
589                .contains("copy")
590        );
591        assert!(
592            rename_file_error(from, to, err())
593                .to_string()
594                .contains("rename")
595        );
596        assert!(
597            move_file_error(from, to, err())
598                .to_string()
599                .contains("move")
600        );
601        assert!(
602            remove_file_error(from, err())
603                .to_string()
604                .contains("remove")
605        );
606        assert!(
607            create_temp_file_error(from, err())
608                .to_string()
609                .contains("create temp file")
610        );
611        assert!(
612            write_temp_file_error(from, err())
613                .to_string()
614                .contains("write temp file")
615        );
616        assert!(
617            sync_temp_file_error(from, err())
618                .to_string()
619                .contains("sync temp file")
620        );
621        assert!(
622            unique_temp_file_error(to, WRITE_ATOMIC_TEMP_ATTEMPTS)
623                .to_string()
624                .contains("unique temp file")
625        );
626        assert!(exists_from_metadata(from, Err(err())).is_err());
627        assert!(should_retry_temp_open(&std::io::Error::new(
628            std::io::ErrorKind::AlreadyExists,
629            "exists",
630        )));
631        assert!(!should_retry_temp_open(&err()));
632    }
633
634    #[tokio::test]
635    async fn metadata_reports_symlinks_and_missing_errors() {
636        let root = TempDir::new().unwrap();
637        let missing = root.child("missing.txt").unwrap();
638        assert!(metadata(&missing).await.is_err());
639
640        #[cfg(unix)]
641        {
642            let file = root.write_file("file.txt", b"hello").unwrap();
643            let link = root.child("link.txt").unwrap();
644            std::os::unix::fs::symlink(&file, &link).unwrap();
645            let meta = metadata(&link).await.unwrap();
646            assert_eq!(meta.path, link);
647            assert!(meta.is_symlink);
648            assert!(meta.modified.is_some());
649        }
650    }
651
652    #[tokio::test]
653    async fn persist_and_move_helpers_cover_success_and_errors() {
654        let root = TempDir::new().unwrap();
655        let temp = root.write_file("temp.txt", b"temp").unwrap();
656        let dest = root.child("dest.txt").unwrap();
657        persist_temp_file(&temp, &dest).await.unwrap();
658        assert_eq!(read_string(&dest).await.unwrap(), "temp");
659
660        let missing = root.child("missing.txt").unwrap();
661        assert!(
662            persist_temp_file(&missing, &root.child("other.txt").unwrap())
663                .await
664                .is_err()
665        );
666
667        let moved = root.child("moved.txt").unwrap();
668        move_file(&dest, &moved).await.unwrap();
669        assert_eq!(read_string(&moved).await.unwrap(), "temp");
670        assert!(
671            move_file(&missing, &root.child("nope.txt").unwrap())
672                .await
673                .is_err()
674        );
675
676        #[cfg(unix)]
677        {
678            let source = root.write_file("cross-device.txt", b"temp").unwrap();
679            let target = root.child("cross-device-moved.txt").unwrap();
680            move_file_after_rename(
681                &source,
682                &target,
683                Err(std::io::Error::from_raw_os_error(libc::EXDEV)),
684            )
685            .await
686            .unwrap();
687            assert_eq!(read_string(&target).await.unwrap(), "temp");
688            assert!(!source.exists());
689        }
690    }
691
692    #[tokio::test]
693    async fn atomic_write_creates_parent_dirs() {
694        let root = TempDir::new().unwrap();
695        let path = root.child("nested/file.txt").unwrap();
696
697        write_atomic(&path, b"atomic", "test").await.unwrap();
698
699        assert_eq!(read_string(&path).await.unwrap(), "atomic");
700    }
701
702    #[tokio::test]
703    async fn atomic_replace_overwrites_existing_files() {
704        let root = TempDir::new().unwrap();
705        let path = root.write_file("file.txt", b"old").unwrap();
706
707        write_atomic_replace(&path, b"new", "test").await.unwrap();
708
709        assert_eq!(read_string(&path).await.unwrap(), "new");
710    }
711
712    #[tokio::test]
713    async fn atomic_write_sanitizes_temp_prefix() {
714        let root = TempDir::new().unwrap();
715        let path = root.child("nested/file.txt").unwrap();
716
717        write_atomic(&path, b"atomic", "../escape").await.unwrap();
718
719        assert_eq!(read_string(&path).await.unwrap(), "atomic");
720        assert!(!root.child("escape").unwrap().exists());
721    }
722
723    #[tokio::test]
724    async fn atomic_write_reports_destination_parent_errors() {
725        let root = TempDir::new().unwrap();
726        root.write_file("file.txt", b"hello").unwrap();
727        let path = root.child("file.txt/nested.txt").unwrap();
728
729        assert!(write_atomic(&path, b"atomic", "test").await.is_err());
730    }
731
732    #[tokio::test]
733    async fn atomic_write_reports_persist_and_attempt_errors() {
734        let root = TempDir::new().unwrap();
735        let dest_dir = root.child("dest").unwrap();
736        crate::async_io::dir::create_all(&dest_dir).await.unwrap();
737
738        assert!(write_atomic(&dest_dir, b"atomic", "test").await.is_err());
739        assert!(
740            write_atomic_with_attempts(
741                &root.child("file.txt").unwrap(),
742                b"atomic",
743                "test",
744                0,
745                false
746            )
747            .await
748            .is_err()
749        );
750        assert!(
751            write_atomic(
752                &root.child("too-long.txt").unwrap(),
753                b"atomic",
754                &"x".repeat(300)
755            )
756            .await
757            .is_err()
758        );
759    }
760
761    #[tokio::test]
762    async fn replace_policy_still_rejects_destination_directories() {
763        let root = TempDir::new().unwrap();
764        let temp = root.write_file("temp.txt", b"temp").unwrap();
765        let dest = root.child("dest").unwrap();
766        crate::async_io::dir::create_all(&dest).await.unwrap();
767
768        assert!(
769            persist_temp_file_with_replace(&temp, &dest, true)
770                .await
771                .is_err()
772        );
773    }
774
775    #[cfg(unix)]
776    #[tokio::test]
777    async fn exists_rejects_symlinks_to_files() {
778        let root = TempDir::new().unwrap();
779        let path = root.child("file.txt").unwrap();
780        let link = root.child("link.txt").unwrap();
781        write(&path, b"hello").await.unwrap();
782        std::os::unix::fs::symlink(&path, &link).unwrap();
783
784        assert!(!exists(&link).await.unwrap());
785    }
786}