feat(cli): move destination opening into Rust

FIO_openDstFile still performed destination classification, platform-specific
file creation, stdio buffering, and overwrite preparation in C, even though
source opening and removal already used Rust filesystem leaves. That made the
CLI backend's most important output safety path split across two implementations.

Add a Rust destination leaf with a small status ABI. Rust owns sentinel and
same-file classification, regular-file detection, platform binary open and
fdopen, truncation, and buffering. C keeps the user-facing diagnostics,
sparse-mode preference mutation, overwrite prompt, and existing remove-file
wrapper; it performs a second Rust open after an accepted overwrite decision so
no prompt or policy is duplicated. Existing file contents are left untouched
until that C-owned decision is complete.

Test Plan:
- `cargo test --manifest-path rust/Cargo.toml --lib -- --test-threads=1`
  -- passed (411 tests, including destination classification/open tests).
- `cargo clippy --manifest-path rust/Cargo.toml --lib -- -D warnings`
  -- passed.
- `cargo clippy --manifest-path rust/cli/Cargo.toml --lib
  --no-default-features --features cli,compression,decompression,benchmark,
  dict-builder -- -D warnings` -- passed.
- Nightly fmt checks for both Rust manifests -- passed.
- `make -B -C tests -j2 test-cli-tests` -- passed (41 tests).
This commit is contained in:
2026-07-18 18:44:12 +02:00
parent 7867f64413
commit 1b85c430b1
2 changed files with 428 additions and 85 deletions
+342 -5
View File
@@ -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<PathBuf> {
}
}
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::<c_void>(), 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::<c_void>();
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::<c_void>();
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();