From cbf1681b7a08fb0659edf9ddb2695e754e2030d3 Mon Sep 17 00:00:00 2001 From: Patrick Elsen Date: Sat, 28 Mar 2026 02:18:38 +0100 Subject: [PATCH 1/2] fallocate: adds implementation implements allocate, collapse-range, dig-holes, insert-range, punch-holes, zero-range and posix_fallocate. --- Cargo.lock | 10 + Cargo.toml | 2 + src/uu/fallocate/Cargo.toml | 16 ++ src/uu/fallocate/fallocate.md | 7 + src/uu/fallocate/src/fallocate.rs | 356 ++++++++++++++++++++++++++++++ src/uu/fallocate/src/main.rs | 1 + tests/by-util/test_fallocate.rs | 121 ++++++++++ tests/tests.rs | 4 + 8 files changed, 517 insertions(+) create mode 100644 src/uu/fallocate/Cargo.toml create mode 100644 src/uu/fallocate/fallocate.md create mode 100644 src/uu/fallocate/src/fallocate.rs create mode 100644 src/uu/fallocate/src/main.rs create mode 100644 tests/by-util/test_fallocate.rs diff --git a/Cargo.lock b/Cargo.lock index b1b7ef57..0bc316a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1502,6 +1502,7 @@ dependencies = [ "uu_chcpu", "uu_ctrlaltdel", "uu_dmesg", + "uu_fallocate", "uu_fsfreeze", "uu_hexdump", "uu_last", @@ -1577,6 +1578,15 @@ dependencies = [ "uucore 0.2.2", ] +[[package]] +name = "uu_fallocate" +version = "0.0.1" +dependencies = [ + "clap", + "libc", + "uucore 0.2.2", +] + [[package]] name = "uu_fsfreeze" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index b05af895..0746353b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ feat_common_core = [ "chcpu", "ctrlaltdel", "dmesg", + "fallocate", "fsfreeze", "hexdump", "last", @@ -99,6 +100,7 @@ cal = { optional = true, version = "0.0.1", package = "uu_cal", path = "src/uu/c chcpu = { optional = true, version = "0.0.1", package = "uu_chcpu", path = "src/uu/chcpu" } ctrlaltdel = { optional = true, version = "0.0.1", package = "uu_ctrlaltdel", path = "src/uu/ctrlaltdel" } dmesg = { optional = true, version = "0.0.1", package = "uu_dmesg", path = "src/uu/dmesg" } +fallocate = { optional = true, version = "0.0.1", package = "uu_fallocate", path = "src/uu/fallocate" } fsfreeze = { optional = true, version = "0.0.1", package = "uu_fsfreeze", path = "src/uu/fsfreeze" } hexdump = { optional = true, version = "0.0.1", package = "uu_hexdump", path = "src/uu/hexdump" } last = { optional = true, version = "0.0.1", package = "uu_last", path = "src/uu/last" } diff --git a/src/uu/fallocate/Cargo.toml b/src/uu/fallocate/Cargo.toml new file mode 100644 index 00000000..2482e8c6 --- /dev/null +++ b/src/uu/fallocate/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "uu_fallocate" +version = "0.0.1" +edition = "2021" + +[dependencies] +uucore = { workspace = true, features = ["parser"] } +clap = { workspace = true } +libc = { workspace = true } + +[lib] +path = "src/fallocate.rs" + +[[bin]] +name = "fallocate" +path = "src/main.rs" diff --git a/src/uu/fallocate/fallocate.md b/src/uu/fallocate/fallocate.md new file mode 100644 index 00000000..976ab6c3 --- /dev/null +++ b/src/uu/fallocate/fallocate.md @@ -0,0 +1,7 @@ +# fallocate + +``` +fallocate [options] +``` + +Preallocate or deallocate space to a file. diff --git a/src/uu/fallocate/src/fallocate.rs b/src/uu/fallocate/src/fallocate.rs new file mode 100644 index 00000000..3584e924 --- /dev/null +++ b/src/uu/fallocate/src/fallocate.rs @@ -0,0 +1,356 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use clap::{crate_version, Arg, ArgAction, ArgGroup, Command}; +use std::fs::OpenOptions; +use std::io; +use std::os::fd::AsRawFd; +use uucore::error::{UResult, USimpleError}; +use uucore::parser::parse_size; +use uucore::{format_usage, help_about, help_usage}; + +const ABOUT: &str = help_about!("fallocate.md"); +const USAGE: &str = help_usage!("fallocate.md"); + +mod options { + pub const COLLAPSE_RANGE: &str = "collapse-range"; + pub const DIG_HOLES: &str = "dig-holes"; + pub const INSERT_RANGE: &str = "insert-range"; + pub const LENGTH: &str = "length"; + pub const KEEP_SIZE: &str = "keep-size"; + pub const OFFSET: &str = "offset"; + pub const PUNCH_HOLE: &str = "punch-hole"; + pub const ZERO_RANGE: &str = "zero-range"; + pub const POSIX: &str = "posix"; + pub const VERBOSE: &str = "verbose"; + pub const FILENAME: &str = "filename"; +} + +fn parse_length_or_offset(s: &str) -> Result { + parse_size::parse_size_u64(s).map_err(|e| format!("failed to parse size: {e}")) +} + +#[cfg(target_os = "linux")] +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let matches = uu_app().try_get_matches_from(args)?; + + let filename = matches.get_one::(options::FILENAME).unwrap(); + let verbose = matches.get_flag(options::VERBOSE); + + let offset = matches + .get_one::(options::OFFSET) + .map(|s| parse_length_or_offset(s)) + .transpose() + .map_err(|e| USimpleError::new(1, e))? + .unwrap_or(0); + + let collapse = matches.get_flag(options::COLLAPSE_RANGE); + let dig_holes = matches.get_flag(options::DIG_HOLES); + let insert = matches.get_flag(options::INSERT_RANGE); + let punch = matches.get_flag(options::PUNCH_HOLE); + let zero = matches.get_flag(options::ZERO_RANGE); + let posix = matches.get_flag(options::POSIX); + let keep_size = matches.get_flag(options::KEEP_SIZE); + + // -l is required except for dig-holes mode + let length = matches + .get_one::(options::LENGTH) + .map(|s| parse_length_or_offset(s)) + .transpose() + .map_err(|e| USimpleError::new(1, e))?; + + if !dig_holes && length.is_none() { + return Err(USimpleError::new(1, "required length was not specified")); + } + + // Open the file: create if doing default allocation or posix, otherwise must exist + let needs_create = !collapse && !dig_holes && !insert && !punch && !zero; + let file = OpenOptions::new() + .read(true) + .write(true) + .create(needs_create) + .open(filename) + .map_err(|e| USimpleError::new(1, format!("cannot open {filename}: {e}")))?; + + if dig_holes { + return dig_holes_in_file(&file, offset, length); + } + + let length = length.unwrap(); + + if posix { + do_posix_fallocate(&file, offset, length, verbose, filename) + } else { + let mut mode: libc::c_int = 0; + if keep_size || punch { + mode |= libc::FALLOC_FL_KEEP_SIZE; + } + if punch { + mode |= libc::FALLOC_FL_PUNCH_HOLE; + } + if collapse { + mode |= libc::FALLOC_FL_COLLAPSE_RANGE; + } + if zero { + mode |= libc::FALLOC_FL_ZERO_RANGE; + } + if insert { + mode |= libc::FALLOC_FL_INSERT_RANGE; + } + + let ret = unsafe { libc::fallocate(file.as_raw_fd(), mode, offset as i64, length as i64) }; + if ret != 0 { + let err = io::Error::last_os_error(); + return Err(USimpleError::new(1, format!("fallocate failed: {err}"))); + } + + if verbose { + let human = humanize_bytes(length); + eprintln!("{filename}: {human} ({length} bytes) allocated."); + } + + Ok(()) + } +} + +#[cfg(target_os = "linux")] +fn do_posix_fallocate( + file: &std::fs::File, + offset: u64, + length: u64, + verbose: bool, + filename: &str, +) -> UResult<()> { + let ret = unsafe { libc::posix_fallocate(file.as_raw_fd(), offset as i64, length as i64) }; + if ret != 0 { + let err = io::Error::from_raw_os_error(ret); + return Err(USimpleError::new(1, format!("fallocate failed: {err}"))); + } + + if verbose { + let human = humanize_bytes(length); + eprintln!("{filename}: {human} ({length} bytes) allocated."); + } + + Ok(()) +} + +/// Dig holes: find zero-filled regions and punch them out to make the file sparse. +/// Uses SEEK_DATA/SEEK_HOLE to find data and hole boundaries, then punches out +/// zero-filled data regions. +#[cfg(target_os = "linux")] +fn dig_holes_in_file(file: &std::fs::File, offset: u64, length: Option) -> UResult<()> { + use std::os::unix::fs::MetadataExt; + + let fd = file.as_raw_fd(); + let file_size = file.metadata()?.size(); + let end = match length { + Some(l) => std::cmp::min(offset + l, file_size), + None => file_size, + }; + + let mut pos = offset as i64; + + loop { + // Seek to the next data region + let data_start = unsafe { libc::lseek(fd, pos, libc::SEEK_DATA) }; + if data_start < 0 { + // ENXIO means no more data regions - we're done + let err = io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::ENXIO) { + break; + } + return Err(USimpleError::new(1, format!("seek failed: {err}"))); + } + if data_start as u64 >= end { + break; + } + + // Find the end of this data region (start of next hole) + let hole_start = unsafe { libc::lseek(fd, data_start, libc::SEEK_HOLE) }; + if hole_start < 0 { + let err = io::Error::last_os_error(); + return Err(USimpleError::new(1, format!("seek failed: {err}"))); + } + + let region_start = data_start as u64; + let region_end = std::cmp::min(hole_start as u64, end); + + if region_start < region_end { + // Read the data region and check if it's all zeros + // Process in chunks to avoid huge allocations + const CHUNK_SIZE: u64 = 64 * 1024; + let mut chunk_offset = region_start; + while chunk_offset < region_end { + let chunk_len = std::cmp::min(CHUNK_SIZE, region_end - chunk_offset) as usize; + let mut buf = vec![0u8; chunk_len]; + + // pread to avoid seeking + let n = unsafe { + libc::pread(fd, buf.as_mut_ptr().cast(), chunk_len, chunk_offset as i64) + }; + if n < 0 { + let err = io::Error::last_os_error(); + return Err(USimpleError::new(1, format!("read failed: {err}"))); + } + let n = n as usize; + buf.truncate(n); + + if buf.iter().all(|&b| b == 0) && !buf.is_empty() { + // This chunk is all zeros - punch a hole + let mode = libc::FALLOC_FL_KEEP_SIZE | libc::FALLOC_FL_PUNCH_HOLE; + let ret = unsafe { libc::fallocate(fd, mode, chunk_offset as i64, n as i64) }; + if ret != 0 { + let err = io::Error::last_os_error(); + return Err(USimpleError::new(1, format!("fallocate failed: {err}"))); + } + } + + chunk_offset += n as u64; + if n == 0 { + break; + } + } + } + + pos = hole_start; + } + + Ok(()) +} + +fn humanize_bytes(bytes: u64) -> String { + const UNITS: &[(u64, &str)] = &[ + (1 << 60, "EiB"), + (1 << 50, "PiB"), + (1 << 40, "TiB"), + (1 << 30, "GiB"), + (1 << 20, "MiB"), + (1 << 10, "KiB"), + ]; + + for &(threshold, unit) in UNITS { + if bytes >= threshold { + let value = bytes as f64 / threshold as f64; + // Match util-linux format: whole numbers without decimal + if bytes % threshold == 0 { + return format!("{} {}", bytes / threshold, unit); + } + return format!("{value:.1} {unit}"); + } + } + format!("{bytes} B") +} + +#[cfg(not(target_os = "linux"))] +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let _matches = uu_app().try_get_matches_from(args)?; + Err(USimpleError::new( + 1, + "`fallocate` is available only on Linux.", + )) +} + +pub fn uu_app() -> Command { + Command::new(uucore::util_name()) + .version(crate_version!()) + .about(ABOUT) + .override_usage(format_usage(USAGE)) + .infer_long_args(true) + .arg( + Arg::new(options::COLLAPSE_RANGE) + .short('c') + .long("collapse-range") + .help("remove a range from the file") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::DIG_HOLES) + .short('d') + .long("dig-holes") + .help("detect zeroes and replace with holes") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::INSERT_RANGE) + .short('i') + .long("insert-range") + .help("insert a hole at range, shifting existing data") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::LENGTH) + .short('l') + .long("length") + .help("length for range operations, in bytes") + .value_name("num") + .action(ArgAction::Set), + ) + .arg( + Arg::new(options::KEEP_SIZE) + .short('n') + .long("keep-size") + .help("maintain the apparent size of the file") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::OFFSET) + .short('o') + .long("offset") + .help("offset for range operations, in bytes") + .value_name("num") + .action(ArgAction::Set), + ) + .arg( + Arg::new(options::PUNCH_HOLE) + .short('p') + .long("punch-hole") + .help("replace a range with a hole (implies -n)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::ZERO_RANGE) + .short('z') + .long("zero-range") + .help("zero and ensure allocation of a range") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::POSIX) + .short('x') + .long("posix") + .help("use posix_fallocate(3) instead of fallocate(2)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::VERBOSE) + .short('v') + .long("verbose") + .help("verbose mode") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::FILENAME) + .value_name("filename") + .help("target file") + .required(true) + .index(1) + .action(ArgAction::Set), + ) + .group( + ArgGroup::new("mode") + .args([ + options::COLLAPSE_RANGE, + options::DIG_HOLES, + options::INSERT_RANGE, + options::PUNCH_HOLE, + options::ZERO_RANGE, + options::POSIX, + ]) + .multiple(false), + ) +} diff --git a/src/uu/fallocate/src/main.rs b/src/uu/fallocate/src/main.rs new file mode 100644 index 00000000..7c5023d9 --- /dev/null +++ b/src/uu/fallocate/src/main.rs @@ -0,0 +1 @@ +uucore::bin!(uu_fallocate); diff --git a/tests/by-util/test_fallocate.rs b/tests/by-util/test_fallocate.rs new file mode 100644 index 00000000..9e0b350d --- /dev/null +++ b/tests/by-util/test_fallocate.rs @@ -0,0 +1,121 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use uutests::{at_and_ucmd, new_ucmd}; + +#[test] +fn test_invalid_arg() { + new_ucmd!().arg("--definitely-invalid").fails().code_is(1); +} + +#[test] +fn test_missing_length() { + new_ucmd!().arg("testfile").fails().code_is(1); +} + +#[test] +fn test_basic_allocate() { + let (at, mut ucmd) = at_and_ucmd!(); + + ucmd.args(&["-l", "1M", "testfile"]).succeeds().no_output(); + + let metadata = at.metadata("testfile"); + assert_eq!(metadata.len(), 1048576); +} + +#[test] +fn test_allocate_with_offset() { + let (at, mut ucmd) = at_and_ucmd!(); + + ucmd.args(&["-l", "4096", "-o", "4096", "testfile"]) + .succeeds() + .no_output(); + + let metadata = at.metadata("testfile"); + assert_eq!(metadata.len(), 8192); +} + +#[test] +fn test_keep_size() { + let (at, mut ucmd) = at_and_ucmd!(); + + ucmd.args(&["-n", "-l", "1M", "testfile"]) + .succeeds() + .no_output(); + + let metadata = at.metadata("testfile"); + // With keep-size, the apparent file size should be 0 since the file was new + assert_eq!(metadata.len(), 0); +} + +#[test] +fn test_verbose() { + let (at, mut ucmd) = at_and_ucmd!(); + + ucmd.args(&["-v", "-l", "1M", "testfile"]) + .succeeds() + .stderr_contains("1 MiB (1048576 bytes) allocated."); + + let metadata = at.metadata("testfile"); + assert_eq!(metadata.len(), 1048576); +} + +#[test] +fn test_posix_mode() { + let (at, mut ucmd) = at_and_ucmd!(); + + ucmd.args(&["-x", "-l", "1M", "testfile"]) + .succeeds() + .no_output(); + + let metadata = at.metadata("testfile"); + assert_eq!(metadata.len(), 1048576); +} + +#[test] +fn test_mutually_exclusive_modes() { + new_ucmd!() + .args(&["-c", "-p", "-l", "4096", "testfile"]) + .fails() + .code_is(1); +} + +#[test] +fn test_punch_hole() { + let (at, mut ucmd) = at_and_ucmd!(); + + // Create a 1M file using standard fs + let path = at.plus("testfile"); + std::fs::write(&path, &vec![0xFFu8; 1048576]).unwrap(); + + ucmd.args(&["-p", "-o", "4096", "-l", "4096", "testfile"]) + .succeeds() + .no_output(); + + // Size should remain the same (punch-hole implies keep-size) + let metadata = at.metadata("testfile"); + assert_eq!(metadata.len(), 1048576); +} + +#[test] +fn test_size_suffixes() { + let (at, mut ucmd) = at_and_ucmd!(); + + ucmd.args(&["-l", "1KiB", "testfile"]) + .succeeds() + .no_output(); + + let metadata = at.metadata("testfile"); + assert_eq!(metadata.len(), 1024); +} + +#[test] +fn test_nonexistent_file_for_punch() { + new_ucmd!() + .args(&["-p", "-l", "4096", "nonexistent"]) + .fails() + .code_is(1) + .stderr_contains("cannot open"); +} diff --git a/tests/tests.rs b/tests/tests.rs index 8f94a09a..49cb438f 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -75,6 +75,10 @@ mod test_last; #[path = "by-util/test_dmesg.rs"] mod test_dmesg; +#[cfg(feature = "fallocate")] +#[path = "by-util/test_fallocate.rs"] +mod test_fallocate; + #[cfg(feature = "fsfreeze")] #[path = "by-util/test_fsfreeze.rs"] mod test_fsfreeze; From bc68612ff8c42da163c8a9a7f7bb0336c955ab0a Mon Sep 17 00:00:00 2001 From: Patrick Elsen Date: Sat, 28 Mar 2026 13:51:06 +0100 Subject: [PATCH 2/2] fallocate: fix CI for clippy, macOS, and Windows - Replace manual `bytes % threshold == 0` with `bytes.is_multiple_of(threshold)` - Gate Linux-only imports and helpers with `#[cfg(target_os = "linux")]` - Gate Linux-only tests with `#[cfg(target_os = "linux")]` --- src/uu/fallocate/src/fallocate.rs | 8 +++++++- tests/by-util/test_fallocate.rs | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/uu/fallocate/src/fallocate.rs b/src/uu/fallocate/src/fallocate.rs index 3584e924..9d88ffad 100644 --- a/src/uu/fallocate/src/fallocate.rs +++ b/src/uu/fallocate/src/fallocate.rs @@ -4,10 +4,14 @@ // file that was distributed with this source code. use clap::{crate_version, Arg, ArgAction, ArgGroup, Command}; +#[cfg(target_os = "linux")] use std::fs::OpenOptions; +#[cfg(target_os = "linux")] use std::io; +#[cfg(target_os = "linux")] use std::os::fd::AsRawFd; use uucore::error::{UResult, USimpleError}; +#[cfg(target_os = "linux")] use uucore::parser::parse_size; use uucore::{format_usage, help_about, help_usage}; @@ -28,6 +32,7 @@ mod options { pub const FILENAME: &str = "filename"; } +#[cfg(target_os = "linux")] fn parse_length_or_offset(s: &str) -> Result { parse_size::parse_size_u64(s).map_err(|e| format!("failed to parse size: {e}")) } @@ -222,6 +227,7 @@ fn dig_holes_in_file(file: &std::fs::File, offset: u64, length: Option) -> Ok(()) } +#[cfg(target_os = "linux")] fn humanize_bytes(bytes: u64) -> String { const UNITS: &[(u64, &str)] = &[ (1 << 60, "EiB"), @@ -236,7 +242,7 @@ fn humanize_bytes(bytes: u64) -> String { if bytes >= threshold { let value = bytes as f64 / threshold as f64; // Match util-linux format: whole numbers without decimal - if bytes % threshold == 0 { + if bytes.is_multiple_of(threshold) { return format!("{} {}", bytes / threshold, unit); } return format!("{value:.1} {unit}"); diff --git a/tests/by-util/test_fallocate.rs b/tests/by-util/test_fallocate.rs index 9e0b350d..b26af868 100644 --- a/tests/by-util/test_fallocate.rs +++ b/tests/by-util/test_fallocate.rs @@ -16,6 +16,7 @@ fn test_missing_length() { } #[test] +#[cfg(target_os = "linux")] fn test_basic_allocate() { let (at, mut ucmd) = at_and_ucmd!(); @@ -26,6 +27,7 @@ fn test_basic_allocate() { } #[test] +#[cfg(target_os = "linux")] fn test_allocate_with_offset() { let (at, mut ucmd) = at_and_ucmd!(); @@ -38,6 +40,7 @@ fn test_allocate_with_offset() { } #[test] +#[cfg(target_os = "linux")] fn test_keep_size() { let (at, mut ucmd) = at_and_ucmd!(); @@ -51,6 +54,7 @@ fn test_keep_size() { } #[test] +#[cfg(target_os = "linux")] fn test_verbose() { let (at, mut ucmd) = at_and_ucmd!(); @@ -63,6 +67,7 @@ fn test_verbose() { } #[test] +#[cfg(target_os = "linux")] fn test_posix_mode() { let (at, mut ucmd) = at_and_ucmd!(); @@ -83,6 +88,7 @@ fn test_mutually_exclusive_modes() { } #[test] +#[cfg(target_os = "linux")] fn test_punch_hole() { let (at, mut ucmd) = at_and_ucmd!(); @@ -100,6 +106,7 @@ fn test_punch_hole() { } #[test] +#[cfg(target_os = "linux")] fn test_size_suffixes() { let (at, mut ucmd) = at_and_ucmd!(); @@ -112,6 +119,7 @@ fn test_size_suffixes() { } #[test] +#[cfg(target_os = "linux")] fn test_nonexistent_file_for_punch() { new_ucmd!() .args(&["-p", "-l", "4096", "nonexistent"])