diff --git a/programs/fileio.c b/programs/fileio.c index 8c8e854c2..eeb6aa1a2 100644 --- a/programs/fileio.c +++ b/programs/fileio.c @@ -361,6 +361,23 @@ int FIO_rust_openSrcFile(int allowBlockDevices, const char* srcFileName, stat_t* statbuf, FILE** outFile); +enum { + FIO_RUST_OPEN_DST_SUCCESS = 0, + FIO_RUST_OPEN_DST_SETBUF_FAILED = 1, + FIO_RUST_OPEN_DST_TEST_MODE = 2, + FIO_RUST_OPEN_DST_STDOUT = 3, + FIO_RUST_OPEN_DST_SAME_FILE = 4, + FIO_RUST_OPEN_DST_EXISTING = 5, + FIO_RUST_OPEN_DST_NULL_DEVICE_REGULAR = 6, + FIO_RUST_OPEN_DST_OPEN_FAILED = 7, +}; +int FIO_rust_openDstFile(int testMode, + int allowExisting, + const char* srcFileName, + const char* dstFileName, + int mode, + int* isDstRegFile, + FILE** outFile); int FIO_rust_setDictBufferMalloc(const char* fileName, unsigned long long expectedFileSize, size_t maxSize, @@ -478,99 +495,88 @@ FIO_openDstFile(FIO_ctx_t* fCtx, FIO_prefs_t* const prefs, const char* srcFileName, const char* dstFileName, const int mode) { - int isDstRegFile; + int isDstRegFile = 0; + int allowExisting = 0; + int sparseAdjusted = 0; + int status; + FILE* f = NULL; - if (prefs->testMode) return NULL; /* do not open file in test mode */ + status = FIO_rust_openDstFile(prefs->testMode, allowExisting, + srcFileName, dstFileName, mode, + &isDstRegFile, &f); + if (status == FIO_RUST_OPEN_DST_TEST_MODE) + return NULL; /* do not open file in test mode */ assert(dstFileName != NULL); - if (!strcmp (dstFileName, stdoutmark)) { - DISPLAYLEVEL(4,"Using stdout for output \n"); - SET_BINARY_MODE(stdout); - if (prefs->sparseFileSupport == 1) { - prefs->sparseFileSupport = 0; - DISPLAYLEVEL(4, "Sparse File Support is automatically disabled on stdout ; try --sparse \n"); + for (;;) { + if (status == FIO_RUST_OPEN_DST_STDOUT) { + DISPLAYLEVEL(4,"Using stdout for output \n"); + SET_BINARY_MODE(stdout); + if (prefs->sparseFileSupport == 1) { + prefs->sparseFileSupport = 0; + DISPLAYLEVEL(4, "Sparse File Support is automatically disabled on stdout ; try --sparse \n"); + } + return stdout; } - return stdout; - } - /* ensure dst is not the same as src */ - if (srcFileName != NULL && UTIL_isSameFile(srcFileName, dstFileName)) { - DISPLAYLEVEL(1, "zstd: Refusing to open an output file which will overwrite the input file \n"); - return NULL; - } - - isDstRegFile = UTIL_isRegularFile(dstFileName); /* invoke once */ - if (prefs->sparseFileSupport == 1) { - prefs->sparseFileSupport = ZSTD_SPARSE_DEFAULT; - if (!isDstRegFile) { - prefs->sparseFileSupport = 0; - DISPLAYLEVEL(4, "Sparse File Support is disabled when output is not a file \n"); + if (status == FIO_RUST_OPEN_DST_SAME_FILE) { + DISPLAYLEVEL(1, "zstd: Refusing to open an output file which will overwrite the input file \n"); + return NULL; } - } - if (isDstRegFile) { - /* Check if destination file already exists */ -#if !defined(_WIN32) - /* this test does not work on Windows : - * `NUL` and `nul` are detected as regular files */ - if (!strcmp(dstFileName, nulmark)) { + /* Keep the C-owned preference mutation and its diagnostics here. Do + * this only for the first classification: after an existing file is + * removed, the second Rust call must not reclassify it as a missing + * destination and emit a different sparse-mode message. */ + if (!sparseAdjusted) { + if (prefs->sparseFileSupport == 1) { + prefs->sparseFileSupport = ZSTD_SPARSE_DEFAULT; + if (!isDstRegFile) { + prefs->sparseFileSupport = 0; + DISPLAYLEVEL(4, "Sparse File Support is disabled when output is not a file \n"); + } + } + sparseAdjusted = 1; + } + + if (status == FIO_RUST_OPEN_DST_NULL_DEVICE_REGULAR) { EXM_THROW(40, "%s is unexpectedly categorized as a regular file", dstFileName); } -#endif - if (!prefs->overwrite) { - if (g_display_prefs.displayLevel <= 1) { - /* No interaction possible */ - DISPLAYLEVEL(1, "zstd: %s already exists; not overwritten \n", - dstFileName); - return NULL; - } - DISPLAY("zstd: %s already exists; ", dstFileName); - if (UTIL_requireUserConfirmation("overwrite (y/n) ? ", "Not overwritten \n", "yY", fCtx->hasStdinInput)) - return NULL; - } - /* need to unlink */ - FIO_removeFile(dstFileName); - } - { -#if defined(_WIN32) - /* Windows requires opening the file as a "binary" file to avoid - * mangling. This macro doesn't exist on unix. */ - const int openflags = O_WRONLY|O_CREAT|O_TRUNC|O_BINARY; - const int fd = _open(dstFileName, openflags, mode); - FILE* f = NULL; - if (fd != -1) { - f = _fdopen(fd, "wb"); - } -#else - const int openflags = O_WRONLY|O_CREAT|O_TRUNC; - const int fd = open(dstFileName, openflags, mode); - FILE* f = NULL; - if (fd != -1) { - f = fdopen(fd, "wb"); - } -#endif - if (f == NULL) { - DISPLAYLEVEL(1, "zstd: %s: %s\n", dstFileName, strerror(errno)); - } else { - /* An increased buffer size can provide a significant performance - * boost on some platforms. Note that providing a NULL buf with a - * size that's not 0 is not defined in ANSI C, but is defined in an - * extension. There are three possibilities here: - * 1. Libc supports the extended version and everything is good. - * 2. Libc ignores the size when buf is NULL, in which case - * everything will continue as if we didn't call `setvbuf()`. - * 3. We fail the call and execution continues but a warning - * message might be shown. - * In all cases due execution continues. For now, I believe that - * this is a more cost-effective solution than managing the buffers - * allocations ourselves (will require an API change). - */ - if (setvbuf(f, NULL, _IOFBF, 1 MB)) { - DISPLAYLEVEL(2, "Warning: setvbuf failed for %s\n", dstFileName); + if (status == FIO_RUST_OPEN_DST_EXISTING) { + if (!prefs->overwrite) { + if (g_display_prefs.displayLevel <= 1) { + /* No interaction possible */ + DISPLAYLEVEL(1, "zstd: %s already exists; not overwritten \n", + dstFileName); + return NULL; + } + DISPLAY("zstd: %s already exists; ", dstFileName); + if (UTIL_requireUserConfirmation("overwrite (y/n) ? ", "Not overwritten \n", "yY", fCtx->hasStdinInput)) + return NULL; } + /* Keep the existing C wrapper so its stat/non-regular diagnostics + * remain unchanged. The next Rust call opens with O_TRUNC. */ + FIO_removeFile(dstFileName); + allowExisting = 1; + f = NULL; + status = FIO_rust_openDstFile(prefs->testMode, allowExisting, + srcFileName, dstFileName, mode, + &isDstRegFile, &f); + continue; } + + if (status == FIO_RUST_OPEN_DST_OPEN_FAILED) { + DISPLAYLEVEL(1, "zstd: %s: %s\n", dstFileName, strerror(errno)); + return NULL; + } + + if (status == FIO_RUST_OPEN_DST_SETBUF_FAILED) + DISPLAYLEVEL(2, "Warning: setvbuf failed for %s\n", dstFileName); + + assert(status == FIO_RUST_OPEN_DST_SUCCESS + || status == FIO_RUST_OPEN_DST_SETBUF_FAILED); return f; } } diff --git a/rust/src/fileio_backend.rs b/rust/src/fileio_backend.rs index 1be321a6b..3f8058138 100644 --- a/rust/src/fileio_backend.rs +++ b/rust/src/fileio_backend.rs @@ -3,11 +3,11 @@ //! Rust-owned filesystem leaves for the command-line backend. //! -//! The surrounding CLI still owns policy, diagnostics, and stream orchestration -//! in C. This module implements the filesystem operations behind -//! `FIO_openSrcFile()`, `FIO_removeFile()`, and dictionary loading, returning -//! small status codes so the C wrappers can retain their existing messages and -//! conventions. +//! The surrounding CLI still owns diagnostics and stream orchestration in C. +//! This module implements the filesystem operations behind +//! `FIO_openDstFile()`, `FIO_openSrcFile()`, `FIO_removeFile()`, and dictionary +//! loading, returning small status codes so the C wrappers can retain their +//! existing messages, policy fields, and conventions. use std::ffi::{c_char, c_void, CStr}; use std::fs::File; @@ -44,9 +44,59 @@ const FIO_OPEN_SRC_STAT_FAILED: c_int = 1; const FIO_OPEN_SRC_NON_REGULAR: c_int = 2; const FIO_OPEN_SRC_FOPEN_FAILED: c_int = 3; +const FIO_OPEN_DST_SUCCESS: c_int = 0; +const FIO_OPEN_DST_SETBUF_FAILED: c_int = 1; +const FIO_OPEN_DST_TEST_MODE: c_int = 2; +const FIO_OPEN_DST_STDOUT: c_int = 3; +const FIO_OPEN_DST_SAME_FILE: c_int = 4; +const FIO_OPEN_DST_EXISTING: c_int = 5; +const FIO_OPEN_DST_NULL_DEVICE_REGULAR: c_int = 6; +const FIO_OPEN_DST_OPEN_FAILED: c_int = 7; +const FIO_DESTINATION_BUFFER_SIZE: usize = 1 << 20; + +static STDOUT_MARK: &[u8] = b"/*stdout*\\\0"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DestinationDecision { + TestMode, + Stdout, + SameFile, + NullDeviceRegular, + ExistingRegular, + Create, +} + +fn classify_destination( + test_mode: bool, + is_stdout: bool, + same_file: bool, + is_regular: bool, + is_null_device: bool, + allow_existing: bool, +) -> DestinationDecision { + if test_mode { + return DestinationDecision::TestMode; + } + if is_stdout { + return DestinationDecision::Stdout; + } + if same_file { + return DestinationDecision::SameFile; + } + if is_regular && is_null_device { + return DestinationDecision::NullDeviceRegular; + } + if is_regular && !allow_existing { + return DestinationDecision::ExistingRegular; + } + DestinationDecision::Create +} + unsafe extern "C" { fn UTIL_stat(file_name: *const c_char, stat_buf: *mut libc::stat) -> c_int; fn UTIL_isRegularFileStat(stat_buf: *const libc::stat) -> c_int; + fn UTIL_isRegularFile(file_name: *const c_char) -> c_int; + fn UTIL_isSameFile(file1: *const c_char, file2: *const c_char) -> c_int; fn UTIL_isFIFOStat(stat_buf: *const libc::stat) -> c_int; fn UTIL_isBlockDevStat(stat_buf: *const libc::stat) -> c_int; fn fopen(file_name: *const c_char, mode: *const c_char) -> *mut c_void; @@ -127,6 +177,126 @@ fn path_from_c(path: *const c_char) -> Option { } } +fn c_string_equals(path: *const c_char, expected: &[u8]) -> bool { + if path.is_null() { + return false; + } + unsafe { CStr::from_ptr(path).to_bytes() == &expected[..expected.len() - 1] } +} + +unsafe fn open_destination(path: *const c_char, mode: c_int) -> (*mut c_void, c_int) { + #[cfg(windows)] + let open_flags = libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_BINARY; + #[cfg(not(windows))] + let open_flags = libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC; + + let fd = unsafe { libc::open(path, open_flags, mode) }; + if fd == -1 { + return (ptr::null_mut(), FIO_OPEN_DST_OPEN_FAILED); + } + + // This intentionally follows the original C leaf: if fdopen fails, the + // caller observes its errno through the C diagnostic path. In particular, + // do not perform another errno-setting operation before returning. + let file = unsafe { libc::fdopen(fd, c"wb".as_ptr()) }; + if file.is_null() { + return (ptr::null_mut(), FIO_OPEN_DST_OPEN_FAILED); + } + + unsafe { + if libc::setvbuf( + file, + ptr::null_mut(), + 0, // _IOFBF + FIO_DESTINATION_BUFFER_SIZE, + ) != 0 + { + // The stream remains usable. The caller needs the warning status + // so C can preserve the original user-facing diagnostic. + return (file.cast::(), FIO_OPEN_DST_SETBUF_FAILED); + } + } + (file.cast(), FIO_OPEN_DST_SUCCESS) +} + +/// Classifies and opens a non-policy destination path for the C adapter. +/// +/// `allow_existing` is false for the first call. A regular destination then +/// returns `FIO_OPEN_DST_EXISTING` without changing it, allowing C to retain +/// its overwrite prompt and `FIO_removeFile()` diagnostics. C calls again +/// with `allow_existing` true after that policy decision. The returned +/// `FILE*` remains owned and closed by C. +#[no_mangle] +pub unsafe extern "C" fn FIO_rust_openDstFile( + test_mode: c_int, + allow_existing: c_int, + src_file_name: *const c_char, + dst_file_name: *const c_char, + mode: c_int, + is_dst_reg_file: *mut c_int, + out_file: *mut *mut c_void, +) -> c_int { + if is_dst_reg_file.is_null() || out_file.is_null() { + return FIO_OPEN_DST_OPEN_FAILED; + } + unsafe { + *is_dst_reg_file = 0; + *out_file = ptr::null_mut(); + } + + if test_mode != 0 { + return FIO_OPEN_DST_TEST_MODE; + } + if dst_file_name.is_null() { + return FIO_OPEN_DST_OPEN_FAILED; + } + + let is_stdout = c_string_equals(dst_file_name, STDOUT_MARK); + let same_file = !is_stdout + && !src_file_name.is_null() + && unsafe { UTIL_isSameFile(src_file_name, dst_file_name) != 0 }; + if same_file { + return FIO_OPEN_DST_SAME_FILE; + } + + let is_regular = !is_stdout && unsafe { UTIL_isRegularFile(dst_file_name) != 0 }; + let is_null_device = { + #[cfg(not(windows))] + { + is_regular && c_string_equals(dst_file_name, b"/dev/null\0") + } + #[cfg(windows)] + { + false + } + }; + + unsafe { *is_dst_reg_file = is_regular as c_int }; + match classify_destination( + test_mode != 0, + is_stdout, + same_file, + is_regular, + is_null_device, + allow_existing != 0, + ) { + DestinationDecision::TestMode => FIO_OPEN_DST_TEST_MODE, + DestinationDecision::Stdout => FIO_OPEN_DST_STDOUT, + DestinationDecision::SameFile => FIO_OPEN_DST_SAME_FILE, + DestinationDecision::NullDeviceRegular => FIO_OPEN_DST_NULL_DEVICE_REGULAR, + DestinationDecision::ExistingRegular => FIO_OPEN_DST_EXISTING, + DestinationDecision::Create => { + let (file, status) = unsafe { open_destination(dst_file_name, mode) }; + if file.is_null() { + return status; + } + + unsafe { *out_file = file }; + status + } + } +} + /// Stats a dictionary path into the C-owned `stat_t` buffer. /// /// The C wrapper keeps the policy and diagnostics. This leaf only reports @@ -594,6 +764,173 @@ mod tests { } } + #[test] + fn destination_classification_preserves_policy_precedence() { + assert_eq!( + classify_destination(true, true, true, true, true, false), + DestinationDecision::TestMode + ); + assert_eq!( + classify_destination(false, true, true, true, true, false), + DestinationDecision::Stdout + ); + assert_eq!( + classify_destination(false, false, true, true, true, false), + DestinationDecision::SameFile + ); + assert_eq!( + classify_destination(false, false, false, true, true, true), + DestinationDecision::NullDeviceRegular + ); + } + + #[test] + fn destination_classification_requires_explicit_existing_file_permission() { + assert_eq!( + classify_destination(false, false, false, true, false, false), + DestinationDecision::ExistingRegular + ); + assert_eq!( + classify_destination(false, false, false, true, false, true), + DestinationDecision::Create + ); + assert_eq!( + classify_destination(false, false, false, false, true, false), + DestinationDecision::Create + ); + } + + #[test] + fn destination_test_mode_returns_before_path_validation() { + let mut is_dst_reg_file = c_int::MAX; + let mut out_file = ptr::dangling_mut::(); + let status = unsafe { + FIO_rust_openDstFile( + 1, + 0, + ptr::null(), + ptr::null(), + 0o666, + &mut is_dst_reg_file, + &mut out_file, + ) + }; + assert_eq!(status, FIO_OPEN_DST_TEST_MODE); + assert_eq!(is_dst_reg_file, 0); + assert!(out_file.is_null()); + } + + #[test] + fn destination_stdout_is_classified_without_opening_or_statting() { + let dst = CString::new("/*stdout*\\").expect("stdout sentinel contains no NUL"); + let mut is_dst_reg_file = c_int::MAX; + let mut out_file = ptr::dangling_mut::(); + let status = unsafe { + FIO_rust_openDstFile( + 0, + 0, + ptr::null(), + dst.as_ptr(), + 0o666, + &mut is_dst_reg_file, + &mut out_file, + ) + }; + assert_eq!(status, FIO_OPEN_DST_STDOUT); + assert_eq!(is_dst_reg_file, 0); + assert!(out_file.is_null()); + } + + #[test] + fn existing_destination_is_reported_without_modifying_it() { + let path = temp_path("open-existing"); + let payload = b"existing payload"; + fs::write(&path, payload).expect("create temporary file"); + let c_path = c_path(&path); + let mut is_dst_reg_file = 0; + let mut out_file = ptr::null_mut(); + + let status = unsafe { + FIO_rust_openDstFile( + 0, + 0, + ptr::null(), + c_path.as_ptr(), + 0o666, + &mut is_dst_reg_file, + &mut out_file, + ) + }; + assert_eq!(status, FIO_OPEN_DST_EXISTING); + assert_eq!(is_dst_reg_file, 1); + assert!(out_file.is_null()); + assert_eq!(fs::read(&path).expect("read destination"), payload); + + fs::remove_file(&path).expect("remove temporary file"); + } + + #[test] + fn same_source_and_destination_is_refused_before_creation() { + let path = temp_path("open-same"); + fs::write(&path, b"source payload").expect("create temporary file"); + let c_path = c_path(&path); + let mut is_dst_reg_file = 0; + let mut out_file = ptr::null_mut(); + + let status = unsafe { + FIO_rust_openDstFile( + 0, + 1, + c_path.as_ptr(), + c_path.as_ptr(), + 0o666, + &mut is_dst_reg_file, + &mut out_file, + ) + }; + assert_eq!(status, FIO_OPEN_DST_SAME_FILE); + assert_eq!(is_dst_reg_file, 0); + assert!(out_file.is_null()); + assert_eq!(fs::read(&path).expect("read source"), b"source payload"); + + fs::remove_file(&path).expect("remove temporary file"); + } + + #[test] + fn creates_binary_buffered_destination() { + let path = temp_path("open-create"); + let c_path = c_path(&path); + let mut is_dst_reg_file = c_int::MAX; + let mut out_file = ptr::null_mut(); + + let status = unsafe { + FIO_rust_openDstFile( + 0, + 0, + ptr::null(), + c_path.as_ptr(), + 0o666, + &mut is_dst_reg_file, + &mut out_file, + ) + }; + assert!(matches!( + status, + FIO_OPEN_DST_SUCCESS | FIO_OPEN_DST_SETBUF_FAILED + )); + assert_eq!(is_dst_reg_file, 0); + assert!(!out_file.is_null()); + + let payload = b"destination payload"; + let written = + unsafe { libc::fwrite(payload.as_ptr().cast(), 1, payload.len(), out_file.cast()) }; + assert_eq!(written, payload.len()); + assert_eq!(unsafe { libc::fclose(out_file.cast()) }, 0); + assert_eq!(fs::read(&path).expect("read destination"), payload); + + fs::remove_file(&path).expect("remove temporary file"); + } + #[test] fn null_filename_leaves_empty_outputs() { let mut buffer = ptr::null_mut();