Skip to main content

rskit_fs/sync_io/
file.rs

1//! Sync file helpers.
2//!
3//! These helpers use `std::fs` and may block the current thread.
4
5use std::fs::{File, OpenOptions};
6use std::io::Read as _;
7#[cfg(unix)]
8use std::os::unix::fs::OpenOptionsExt as _;
9use std::path::{Path, PathBuf};
10
11use rskit_errors::{AppError, AppResult, ErrorCode};
12
13use crate::types::FileMeta;
14
15use crate::file_error::{file_too_large_error, not_regular_file_error, symlink_not_allowed_error};
16pub use crate::file_error::{
17    is_file_too_large_error, is_not_regular_file_error, is_symlink_not_allowed_error,
18};
19use crate::path::parent_dir;
20use crate::temp::sibling_temp_path;
21
22const WRITE_ATOMIC_TEMP_ATTEMPTS: usize = 16;
23
24/// Create the parent directory for a file path if it has one.
25pub fn create_parent_dir(path: &Path) -> AppResult<()> {
26    if let Some(parent) = parent_dir(path) {
27        std::fs::create_dir_all(parent).map_err(create_parent_dirs_error)?;
28    }
29    Ok(())
30}
31
32/// Open a file for blocking reads.
33pub fn open(path: &Path) -> AppResult<File> {
34    File::open(path).map_err(|error| open_file_error(path, error))
35}
36
37/// Create a file for blocking writes, creating parent directories as needed.
38pub fn create(path: &Path) -> AppResult<File> {
39    create_parent_dir(path)?;
40    File::create(path).map_err(|error| create_file_error(path, error))
41}
42
43/// Return true when `path` exists as a regular file, without following symlinks.
44pub fn exists(path: &Path) -> AppResult<bool> {
45    match std::fs::symlink_metadata(path) {
46        Ok(metadata) => Ok(metadata.is_file() && !metadata.file_type().is_symlink()),
47        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
48        Err(error) => Err(inspect_file_error(path, error)),
49    }
50}
51
52/// Open a regular file without following a final symlink.
53pub fn open_no_follow_regular(path: &Path) -> AppResult<File> {
54    let file = open_no_follow(path)?;
55    let metadata = file
56        .metadata()
57        .map_err(|error| inspect_file_error(path, error))?;
58    if !metadata.is_file() {
59        return Err(not_regular_file_error(path));
60    }
61    Ok(file)
62}
63
64#[cfg(unix)]
65fn open_no_follow(path: &Path) -> AppResult<File> {
66    OpenOptions::new()
67        .read(true)
68        .custom_flags(libc::O_NOFOLLOW)
69        .open(path)
70        .map_err(|error| open_file_error(path, error))
71}
72
73#[cfg(not(unix))]
74fn open_no_follow(path: &Path) -> AppResult<File> {
75    let metadata =
76        std::fs::symlink_metadata(path).map_err(|error| inspect_file_error(path, error))?;
77    if metadata.file_type().is_symlink() {
78        return Err(
79            symlink_not_allowed_error(path).with_cause(std::io::Error::other("path is a symlink"))
80        );
81    }
82    open(path)
83}
84
85/// Read a file into memory.
86pub fn read(path: &Path) -> AppResult<Vec<u8>> {
87    std::fs::read(path).map_err(|error| read_file_error(path, error))
88}
89
90/// Read a UTF-8 text file.
91pub fn read_string(path: &Path) -> AppResult<String> {
92    std::fs::read_to_string(path).map_err(|error| read_file_error(path, error))
93}
94
95/// Read at most `max_bytes` from a regular file without following a final symlink.
96pub fn read_bounded(path: &Path, max_bytes: u64) -> AppResult<Vec<u8>> {
97    let mut file = open_no_follow_regular(path)?;
98    read_bounded_from_file(path, max_bytes, &mut file)
99}
100
101/// Read a UTF-8 text file up to `max_bytes` bytes without following a final symlink.
102pub fn read_string_bounded(path: &Path, max_bytes: u64) -> AppResult<String> {
103    let bytes = read_bounded(path, max_bytes)?;
104    String::from_utf8(bytes).map_err(|error| {
105        AppError::new(
106            ErrorCode::InvalidInput,
107            format!("file '{}' is not valid UTF-8: {error}", path.display()),
108        )
109    })
110}
111
112fn read_bounded_from_file(path: &Path, max_bytes: u64, file: &mut File) -> AppResult<Vec<u8>> {
113    let metadata = file
114        .metadata()
115        .map_err(|error| inspect_file_error(path, error))?;
116    if metadata.is_file() && metadata.len() > max_bytes {
117        return Err(file_too_large_error(path, metadata.len(), max_bytes));
118    }
119
120    let capacity = metadata.len().min(max_bytes).try_into().unwrap_or(0);
121    let mut bytes = Vec::with_capacity(capacity);
122    file.by_ref()
123        .take(max_bytes.saturating_add(1))
124        .read_to_end(&mut bytes)
125        .map_err(|error| read_file_error(path, error))?;
126    if bytes.len() as u64 > max_bytes {
127        return Err(file_too_large_error(path, bytes.len() as u64, max_bytes));
128    }
129    Ok(bytes)
130}
131
132/// Write bytes to a file, creating parent directories as needed.
133pub fn write(path: &Path, bytes: impl AsRef<[u8]>) -> AppResult<()> {
134    create_parent_dir(path)?;
135    std::fs::write(path, bytes).map_err(|error| write_file_error(path, error))
136}
137
138/// Copy one file to another path, creating parent directories as needed.
139pub fn copy(from: &Path, to: &Path) -> AppResult<u64> {
140    create_parent_dir(to)?;
141    std::fs::copy(from, to).map_err(|error| copy_file_error(from, to, error))
142}
143
144/// Rename or move a file, creating the destination parent directory as needed.
145pub fn rename(from: &Path, to: &Path) -> AppResult<()> {
146    create_parent_dir(to)?;
147    std::fs::rename(from, to).map_err(|error| rename_file_error(from, to, error))
148}
149
150/// Move a file, falling back to copy+delete when rename cannot cross filesystems.
151pub fn move_file(from: &Path, to: &Path) -> AppResult<()> {
152    create_parent_dir(to)?;
153    match std::fs::rename(from, to) {
154        Ok(()) => Ok(()),
155        Err(error) if is_cross_device_error(&error) => {
156            copy(from, to)?;
157            remove(from)
158        }
159        Err(error) => Err(move_file_error(from, to, error)),
160    }
161}
162
163/// Remove a file.
164pub fn remove(path: &Path) -> AppResult<()> {
165    std::fs::remove_file(path).map_err(|error| remove_file_error(path, error))
166}
167
168/// Remove a file and ignore `NotFound`.
169pub fn remove_if_exists(path: &Path) -> AppResult<bool> {
170    match std::fs::remove_file(path) {
171        Ok(()) => Ok(true),
172        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
173        Err(error) => Err(remove_file_error(path, error)),
174    }
175}
176
177/// Read file metadata without following symlinks.
178pub fn metadata(path: &Path) -> AppResult<FileMeta> {
179    let metadata =
180        std::fs::symlink_metadata(path).map_err(|error| inspect_file_error(path, error))?;
181    Ok(FileMeta {
182        path: path.to_path_buf(),
183        len: metadata.len(),
184        created: metadata.created().ok(),
185        modified: metadata.modified().ok(),
186        is_file: metadata.is_file(),
187        is_dir: metadata.is_dir(),
188        is_symlink: metadata.file_type().is_symlink(),
189    })
190}
191
192/// Atomically write bytes by writing a sibling temp file and renaming it.
193pub fn write_atomic(dest: &Path, bytes: impl AsRef<[u8]>, temp_prefix: &str) -> AppResult<()> {
194    write_atomic_with_attempts(dest, bytes, temp_prefix, WRITE_ATOMIC_TEMP_ATTEMPTS, false)
195}
196
197/// Atomically write bytes and replace an existing destination when supported.
198///
199/// Replacing an existing destination is atomic on Unix-like platforms. On
200/// Windows, this helper removes the existing file before persisting the temp
201/// file because the platform rename operation cannot replace an existing file.
202pub fn write_atomic_replace(
203    dest: &Path,
204    bytes: impl AsRef<[u8]>,
205    temp_prefix: &str,
206) -> AppResult<()> {
207    write_atomic_with_attempts(dest, bytes, temp_prefix, WRITE_ATOMIC_TEMP_ATTEMPTS, true)
208}
209
210fn write_atomic_with_attempts(
211    dest: &Path,
212    bytes: impl AsRef<[u8]>,
213    temp_prefix: &str,
214    attempts: usize,
215    replace_existing: bool,
216) -> AppResult<()> {
217    create_parent_dir(dest)?;
218    let bytes = bytes.as_ref();
219
220    for _ in 0..attempts {
221        let temp_path = sibling_temp_path(dest, temp_prefix, ".tmp");
222        let mut temp_file = match OpenOptions::new()
223            .write(true)
224            .create_new(true)
225            .open(&temp_path)
226        {
227            Ok(file) => file,
228            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
229            Err(error) => return Err(create_file_error(&temp_path, error)),
230        };
231
232        let result = (|| {
233            use std::io::Write as _;
234            temp_file
235                .write_all(bytes)
236                .map_err(|error| write_file_error(&temp_path, error))?;
237            temp_file
238                .sync_data()
239                .map_err(|error| sync_file_error(&temp_path, error))?;
240            drop(temp_file);
241            persist_temp_file_with_replace(&temp_path, dest, replace_existing)
242        })();
243
244        if result.is_err() {
245            let _ = remove_if_exists(&temp_path);
246        }
247        return result;
248    }
249
250    Err(AppError::new(
251        ErrorCode::Internal,
252        format!(
253            "failed to create a unique temp file for '{}' after {attempts} attempts",
254            dest.display()
255        ),
256    ))
257}
258
259fn persist_temp_file_with_replace(
260    temp_path: &Path,
261    dest: &Path,
262    replace_existing: bool,
263) -> AppResult<()> {
264    #[cfg(windows)]
265    if replace_existing {
266        remove_if_exists(dest)?;
267    }
268
269    let _ = replace_existing;
270    rename(temp_path, dest)
271}
272
273/// Canonicalize a path by resolving symlinks and normalizing components.
274pub fn canonicalize(path: &Path) -> AppResult<PathBuf> {
275    std::fs::canonicalize(path).map_err(|error| {
276        AppError::new(
277            ErrorCode::Internal,
278            format!("failed to canonicalize '{}': {error}", path.display()),
279        )
280    })
281}
282
283fn is_cross_device_error(error: &std::io::Error) -> bool {
284    #[cfg(unix)]
285    {
286        error.raw_os_error() == Some(libc::EXDEV)
287    }
288    #[cfg(not(unix))]
289    {
290        error.kind() == std::io::ErrorKind::CrossesDevices
291    }
292}
293
294fn create_parent_dirs_error(error: std::io::Error) -> AppError {
295    AppError::new(
296        ErrorCode::Internal,
297        format!("failed to create parent dirs: {error}"),
298    )
299    .with_cause(error)
300}
301
302fn inspect_file_error(path: &Path, error: std::io::Error) -> AppError {
303    AppError::new(
304        ErrorCode::Internal,
305        format!("failed to inspect file '{}': {error}", path.display()),
306    )
307    .with_cause(error)
308}
309
310fn open_file_error(path: &Path, error: std::io::Error) -> AppError {
311    if is_symlink_open_error(&error) {
312        return symlink_not_allowed_error(path).with_cause(error);
313    }
314
315    AppError::new(
316        ErrorCode::Internal,
317        format!("failed to open file '{}': {error}", path.display()),
318    )
319    .with_cause(error)
320}
321
322fn is_symlink_open_error(error: &std::io::Error) -> bool {
323    #[cfg(unix)]
324    {
325        error.raw_os_error() == Some(libc::ELOOP)
326    }
327    #[cfg(not(unix))]
328    {
329        false
330    }
331}
332
333fn create_file_error(path: &Path, error: std::io::Error) -> AppError {
334    AppError::new(
335        ErrorCode::Internal,
336        format!("failed to create file '{}': {error}", path.display()),
337    )
338    .with_cause(error)
339}
340
341fn read_file_error(path: &Path, error: std::io::Error) -> AppError {
342    AppError::new(
343        ErrorCode::Internal,
344        format!("failed to read file '{}': {error}", path.display()),
345    )
346    .with_cause(error)
347}
348
349fn write_file_error(path: &Path, error: std::io::Error) -> AppError {
350    AppError::new(
351        ErrorCode::Internal,
352        format!("failed to write file '{}': {error}", path.display()),
353    )
354    .with_cause(error)
355}
356
357fn copy_file_error(from: &Path, to: &Path, error: std::io::Error) -> AppError {
358    AppError::new(
359        ErrorCode::Internal,
360        format!(
361            "failed to copy '{}' to '{}': {error}",
362            from.display(),
363            to.display()
364        ),
365    )
366    .with_cause(error)
367}
368
369fn rename_file_error(from: &Path, to: &Path, error: std::io::Error) -> AppError {
370    AppError::new(
371        ErrorCode::Internal,
372        format!(
373            "failed to rename '{}' to '{}': {error}",
374            from.display(),
375            to.display()
376        ),
377    )
378    .with_cause(error)
379}
380
381fn move_file_error(from: &Path, to: &Path, error: std::io::Error) -> AppError {
382    AppError::new(
383        ErrorCode::Internal,
384        format!(
385            "failed to move '{}' to '{}': {error}",
386            from.display(),
387            to.display()
388        ),
389    )
390    .with_cause(error)
391}
392
393fn remove_file_error(path: &Path, error: std::io::Error) -> AppError {
394    AppError::new(
395        ErrorCode::Internal,
396        format!("failed to remove '{}': {error}", path.display()),
397    )
398    .with_cause(error)
399}
400
401fn sync_file_error(path: &Path, error: std::io::Error) -> AppError {
402    AppError::new(
403        ErrorCode::Internal,
404        format!("failed to sync file '{}': {error}", path.display()),
405    )
406    .with_cause(error)
407}
408
409#[cfg(test)]
410mod tests {
411    use super::{
412        is_file_too_large_error, is_not_regular_file_error, is_symlink_not_allowed_error,
413        persist_temp_file_with_replace, read_bounded, read_string, read_string_bounded,
414        write_atomic_replace,
415    };
416
417    use crate::TempDir;
418
419    #[test]
420    fn bounded_read_accepts_regular_files_within_limit() {
421        let root = TempDir::new().unwrap();
422        let path = root.write_file("file.txt", b"hello").unwrap();
423
424        assert_eq!(read_bounded(&path, 5).unwrap(), b"hello");
425        assert_eq!(read_string_bounded(&path, 5).unwrap(), "hello");
426    }
427
428    #[test]
429    fn bounded_read_rejects_oversized_files() {
430        let root = TempDir::new().unwrap();
431        let path = root.write_file("file.txt", b"hello").unwrap();
432
433        let error = read_bounded(&path, 4).unwrap_err();
434
435        assert!(is_file_too_large_error(&error));
436    }
437
438    #[test]
439    fn bounded_read_rejects_directories() {
440        let root = TempDir::new().unwrap();
441
442        let error = read_bounded(root.path(), 1024).unwrap_err();
443
444        assert!(is_not_regular_file_error(&error));
445    }
446
447    #[cfg(unix)]
448    #[test]
449    fn bounded_read_rejects_final_symlinks() {
450        let root = TempDir::new().unwrap();
451        let target = root.write_file("target.txt", b"hello").unwrap();
452        let link = root.child("link.txt").unwrap();
453        std::os::unix::fs::symlink(&target, &link).unwrap();
454
455        let error = read_bounded(&link, 1024).unwrap_err();
456
457        assert!(is_symlink_not_allowed_error(&error));
458    }
459
460    #[test]
461    fn atomic_replace_overwrites_existing_files() {
462        let root = TempDir::new().unwrap();
463        let path = root.write_file("file.txt", b"old").unwrap();
464
465        write_atomic_replace(&path, b"new", "test").unwrap();
466
467        assert_eq!(read_string(&path).unwrap(), "new");
468    }
469
470    #[test]
471    fn replace_policy_still_rejects_destination_directories() {
472        let root = TempDir::new().unwrap();
473        let temp = root.write_file("temp.txt", b"temp").unwrap();
474        let dest = root.child("dest").unwrap();
475        std::fs::create_dir_all(&dest).unwrap();
476
477        assert!(persist_temp_file_with_replace(&temp, &dest, true).is_err());
478    }
479}