Merge branch 'main' into bug/cp-preserve-xattr-9704

This commit is contained in:
nirv 2025-12-20 09:27:22 +05:30 committed by GitHub
commit b0d6c49630
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 473 additions and 298 deletions

View file

@ -2,7 +2,7 @@ name: GnuTests
# spell-checker:ignore (abbrev/names) CodeCov gnulib GnuTests Swatinem
# spell-checker:ignore (jargon) submodules devel
# spell-checker:ignore (libs/utils) autopoint chksum getenforce gperf lcov libexpect limactl pyinotify setenforce shopt texinfo valgrind libattr libcap taiki-e
# spell-checker:ignore (libs/utils) autopoint chksum dpkg getenforce getlimits gperf lcov libexpect limactl pyinotify setenforce shopt texinfo valgrind libattr libcap taiki-e
# spell-checker:ignore (options) Ccodegen Coverflow Cpanic Zpanic
# spell-checker:ignore (people) Dawid Dziurla * dawidd dtolnay
# spell-checker:ignore (vars) FILESET SUBDIRS XPASS
@ -42,16 +42,6 @@ jobs:
with:
path: 'uutils'
persist-credentials: false
- name: Extract GNU version from build-gnu.sh
id: gnu-version
run: |
GNU_VERSION=$(grep '^release_tag_GNU=' uutils/util/build-gnu.sh | cut -d'"' -f2)
if [ -z "$GNU_VERSION" ]; then
echo "Error: Failed to extract GNU version from build-gnu.sh"
exit 1
fi
echo "REPO_GNU_REF=${GNU_VERSION}" >> $GITHUB_ENV
echo "Extracted GNU version: ${GNU_VERSION}"
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
@ -60,21 +50,18 @@ jobs:
with:
workspaces: "./uutils -> target"
- name: Checkout code (GNU coreutils)
uses: actions/checkout@v6
run: (mkdir -p gnu && cd gnu && bash ../uutils/util/fetch-gnu.sh)
- name: Restore files for faster configure and skipping make
uses: actions/cache@v5
id: cache-config-gnu
with:
repository: 'coreutils/coreutils'
path: 'gnu'
ref: ${{ env.REPO_GNU_REF }}
submodules: false
persist-credentials: false
- name: Override submodule URL and initialize submodules
# Use github instead of upstream git server
run: |
git submodule sync --recursive
git config submodule.gnulib.url https://github.com/coreutils/gnulib.git
git submodule update --init --recursive --depth 1
working-directory: gnu
path: |
gnu/config.cache
gnu/src/getlimits
key: ${{ runner.os }}-gnu-config-${{ env.REPO_GNU_REF }}-${{ hashFiles('gnu/configure') }}
restore-keys: |
${{ runner.os }}-gnu-config-${{ env.REPO_GNU_REF }}-
${{ runner.os }}-gnu-config-
#### Build environment setup
- name: Install dependencies
shell: bash
@ -83,6 +70,8 @@ jobs:
sudo apt-get update
## Check that build-gnu.sh works on the non SELinux system by installing libselinux only on lima
sudo apt-get install -y autopoint gperf gdb python3-pyinotify valgrind libexpect-perl libacl1-dev libattr1-dev libcap-dev attr quilt
curl http://launchpadlibrarian.net/831710181/automake_1.18.1-3_all.deb > automake-1.18.deb
sudo dpkg -i --force-depends automake-1.18.deb
- name: Add various locales
shell: bash
run: |
@ -115,6 +104,15 @@ jobs:
## Build binaries
cd 'uutils'
env PROFILE=release-small bash util/build-gnu.sh
- name: Save files for faster configure and skipping make
uses: actions/cache/save@v5
if: always() && steps.cache-config-gnu.outputs.cache-hit != 'true'
with:
path: |
gnu/config.cache
gnu/src/getlimits
key: ${{ runner.os }}-gnu-config-${{ env.REPO_GNU_REF }}-${{ hashFiles('gnu/configure') }}
### Run tests as user
- name: Run GNU tests
@ -206,16 +204,6 @@ jobs:
with:
path: 'uutils'
persist-credentials: false
- name: Extract GNU version from build-gnu.sh
id: gnu-version-selinux
run: |
GNU_VERSION=$(grep '^release_tag_GNU=' uutils/util/build-gnu.sh | cut -d'"' -f2)
if [ -z "$GNU_VERSION" ]; then
echo "Error: Failed to extract GNU version from build-gnu.sh"
exit 1
fi
echo "REPO_GNU_REF=${GNU_VERSION}" >> $GITHUB_ENV
echo "Extracted GNU version: ${GNU_VERSION}"
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
@ -224,20 +212,7 @@ jobs:
with:
workspaces: "./uutils -> target"
- name: Checkout code (GNU coreutils)
uses: actions/checkout@v6
with:
repository: 'coreutils/coreutils'
path: 'gnu'
ref: ${{ env.REPO_GNU_REF }}
submodules: false
persist-credentials: false
- name: Override submodule URL and initialize submodules
# Use github instead of upstream git server
run: |
git submodule sync --recursive
git config submodule.gnulib.url https://github.com/coreutils/gnulib.git
git submodule update --init --recursive --depth 1
working-directory: gnu
run: (mkdir -p gnu && cd gnu && bash ../uutils/util/fetch-gnu.sh)
#### Lima build environment setup
- name: Setup Lima

View file

@ -76,6 +76,7 @@ iflag
iflags
kibi
kibibytes
langinfo
libacl
lcase
listxattr
@ -129,6 +130,7 @@ semiprimes
setcap
setfacl
setfattr
setlocale
shortcode
shortcodes
siginfo
@ -163,6 +165,8 @@ xattrs
xpass
# * abbreviations
AMPM
ampm
consts
deps
dev

View file

@ -244,8 +244,6 @@ DEBUG=1 bash util/run-gnu-test.sh tests/misc/sm3sum.pl
***Tip:*** First time you run `bash util/build-gnu.sh` command, it will provide instructions on how to checkout GNU coreutils repository at the correct release tag. Please follow those instructions and when done, run `bash util/build-gnu.sh` command again.
Note that GNU test suite relies on individual utilities (not the multicall binary).
You also need to install [quilt](https://savannah.nongnu.org/projects/quilt), a tool used to manage a stack of patches for modifying GNU tests.
On FreeBSD, you need to install packages for GNU coreutils and sed (used in shell scripts instead of system commands):

View file

@ -8,7 +8,7 @@
use clap::{Arg, ArgAction, Command};
use std::ffi::OsString;
use std::fs::File;
use std::io::{self, BufReader, ErrorKind, Read, Write};
use std::io::{self, BufRead, BufReader, ErrorKind, Write};
use std::path::{Path, PathBuf};
use uucore::display::Quotable;
use uucore::encoding::{
@ -146,20 +146,26 @@ pub fn base_app(about: String, usage: String) -> Command {
)
}
pub fn get_input(config: &Config) -> UResult<Box<dyn Read>> {
pub fn get_input(config: &Config) -> UResult<Box<dyn BufRead>> {
match &config.to_read {
Some(path_buf) => {
let file =
File::open(path_buf).map_err_context(|| path_buf.maybe_quote().to_string())?;
Ok(Box::new(BufReader::new(file)))
Ok(Box::new(BufReader::with_capacity(
DEFAULT_BUFFER_SIZE,
file,
)))
}
None => {
// Stdin is already buffered by the OS; wrap once more to reduce syscalls per read.
Ok(Box::new(BufReader::new(io::stdin())))
Ok(Box::new(BufReader::with_capacity(
DEFAULT_BUFFER_SIZE,
io::stdin(),
)))
}
}
}
pub fn handle_input<R: Read>(input: &mut R, format: Format, config: Config) -> UResult<()> {
pub fn handle_input<R: BufRead>(input: &mut R, format: Format, config: Config) -> UResult<()> {
// Always allow padding for Base64 to avoid a full pre-scan of the input.
let supports_fast_decode_and_encode =
get_supports_fast_decode_and_encode(format, config.decode, true);
@ -292,11 +298,11 @@ pub fn get_supports_fast_decode_and_encode(
}
pub mod fast_encode {
use crate::base_common::{DEFAULT_BUFFER_SIZE, WRAP_DEFAULT};
use crate::base_common::WRAP_DEFAULT;
use std::{
cmp::min,
collections::VecDeque,
io::{self, Read, Write},
io::{self, BufRead, Write},
num::NonZeroUsize,
};
use uucore::{
@ -519,7 +525,7 @@ pub mod fast_encode {
/// Remaining bytes are encoded and flushed at the end. I/O or encoding
/// failures are propagated via `UResult`.
pub fn fast_encode_stream(
input: &mut dyn Read,
input: &mut dyn BufRead,
output: &mut dyn Write,
supports_fast_decode_and_encode: &dyn SupportsFastDecodeAndEncode,
wrap: Option<usize>,
@ -544,47 +550,79 @@ pub mod fast_encode {
};
// Buffers
let mut leftover_buffer = VecDeque::<u8>::new();
let mut encoded_buffer = VecDeque::<u8>::new();
let mut read_buffer = vec![0u8; encode_in_chunks_of_size.max(DEFAULT_BUFFER_SIZE)];
let mut leftover_buffer = Vec::<u8>::with_capacity(encode_in_chunks_of_size);
loop {
let read = input
.read(&mut read_buffer)
let read_buffer = input
.fill_buf()
.map_err(|err| USimpleError::new(1, super::format_read_error(err.kind())))?;
if read == 0 {
if read_buffer.is_empty() {
break;
}
leftover_buffer.extend(&read_buffer[..read]);
let mut consumed = 0;
while leftover_buffer.len() >= encode_in_chunks_of_size {
{
let contiguous = leftover_buffer.make_contiguous();
if !leftover_buffer.is_empty() {
let needed = encode_in_chunks_of_size - leftover_buffer.len();
let take = needed.min(read_buffer.len());
leftover_buffer.extend_from_slice(&read_buffer[..take]);
consumed += take;
if leftover_buffer.len() == encode_in_chunks_of_size {
encode_in_chunks_to_buffer(
supports_fast_decode_and_encode,
&contiguous[..encode_in_chunks_of_size],
leftover_buffer.as_slice(),
&mut encoded_buffer,
)?;
leftover_buffer.clear();
write_to_output(
&mut line_wrapping,
&mut encoded_buffer,
output,
false,
wrap == Some(0),
)?;
}
// Drop the data we just encoded
leftover_buffer.drain(..encode_in_chunks_of_size);
write_to_output(
&mut line_wrapping,
&mut encoded_buffer,
output,
false,
wrap == Some(0),
)?;
}
let remaining = &read_buffer[consumed..];
let full_chunk_bytes =
(remaining.len() / encode_in_chunks_of_size) * encode_in_chunks_of_size;
if full_chunk_bytes > 0 {
for chunk in remaining[..full_chunk_bytes].chunks_exact(encode_in_chunks_of_size) {
encode_in_chunks_to_buffer(
supports_fast_decode_and_encode,
chunk,
&mut encoded_buffer,
)?;
write_to_output(
&mut line_wrapping,
&mut encoded_buffer,
output,
false,
wrap == Some(0),
)?;
}
consumed += full_chunk_bytes;
}
if consumed < read_buffer.len() {
leftover_buffer.extend_from_slice(&read_buffer[consumed..]);
consumed = read_buffer.len();
}
input.consume(consumed);
// `leftover_buffer` should never exceed one partial chunk.
debug_assert!(leftover_buffer.len() < encode_in_chunks_of_size);
}
// Encode any remaining bytes and flush
supports_fast_decode_and_encode
.encode_to_vec_deque(leftover_buffer.make_contiguous(), &mut encoded_buffer)?;
.encode_to_vec_deque(&leftover_buffer, &mut encoded_buffer)?;
write_to_output(
&mut line_wrapping,
@ -599,8 +637,7 @@ pub mod fast_encode {
}
pub mod fast_decode {
use crate::base_common::DEFAULT_BUFFER_SIZE;
use std::io::{self, Read, Write};
use std::io::{self, BufRead, Write};
use uucore::{
encoding::SupportsFastDecodeAndEncode,
error::{UResult, USimpleError},
@ -630,7 +667,6 @@ pub mod fast_decode {
fn write_to_output(decoded_buffer: &mut Vec<u8>, output: &mut dyn Write) -> io::Result<()> {
// Write all data in `decoded_buffer` to `output`
output.write_all(decoded_buffer.as_slice())?;
output.flush()?;
decoded_buffer.clear();
@ -764,7 +800,7 @@ pub mod fast_decode {
}
pub fn fast_decode_stream(
input: &mut dyn Read,
input: &mut dyn BufRead,
output: &mut dyn Write,
supports_fast_decode_and_encode: &dyn SupportsFastDecodeAndEncode,
ignore_garbage: bool,
@ -783,17 +819,17 @@ pub mod fast_decode {
let mut buffer = Vec::with_capacity(decode_in_chunks_of_size);
let mut decoded_buffer = Vec::<u8>::new();
let mut read_buffer = [0u8; DEFAULT_BUFFER_SIZE];
loop {
let read = input
.read(&mut read_buffer)
let read_buffer = input
.fill_buf()
.map_err(|err| USimpleError::new(1, super::format_read_error(err.kind())))?;
if read == 0 {
let read_len = read_buffer.len();
if read_len == 0 {
break;
}
for &byte in &read_buffer[..read] {
for &byte in read_buffer {
if byte == b'\n' || byte == b'\r' {
continue;
}
@ -845,6 +881,8 @@ pub mod fast_decode {
buffer.clear();
}
}
input.consume(read_len);
}
if supports_partial_decode {
@ -902,7 +940,7 @@ fn format_read_error(kind: ErrorKind) -> String {
/// Determines if the input buffer contains any padding ('=') ignoring trailing whitespace.
#[cfg(test)]
fn read_and_has_padding<R: Read>(input: &mut R) -> UResult<(bool, Vec<u8>)> {
fn read_and_has_padding<R: std::io::Read>(input: &mut R) -> UResult<(bool, Vec<u8>)> {
let mut buf = Vec::new();
input
.read_to_end(&mut buf)

View file

@ -5,6 +5,8 @@
// spell-checker:ignore strtime ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST
mod locale;
use clap::{Arg, ArgAction, Command};
use jiff::fmt::strtime;
use jiff::tz::{TimeZone, TimeZoneDatabase};
@ -534,7 +536,7 @@ fn make_format_string(settings: &Settings) -> &str {
},
Format::Resolution => "%s.%N",
Format::Custom(ref fmt) => fmt,
Format::Default => "%a %b %e %X %Z %Y",
Format::Default => locale::get_locale_default_format(),
}
}

177
src/uu/date/src/locale.rs Normal file
View file

@ -0,0 +1,177 @@
// This file is part of the uutils coreutils package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
//! Locale detection for time format preferences
// nl_langinfo is available on glibc (Linux), Apple platforms, and BSDs
// but not on Android, Redox or other minimal Unix systems
// Macro to reduce cfg duplication across the module
macro_rules! cfg_langinfo {
($($item:item)*) => {
$(
#[cfg(any(
target_os = "linux",
target_vendor = "apple",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd",
target_os = "dragonfly"
))]
$item
)*
}
}
cfg_langinfo! {
use std::ffi::CStr;
use std::sync::OnceLock;
}
cfg_langinfo! {
/// Cached result of locale time format detection
static TIME_FORMAT_CACHE: OnceLock<bool> = OnceLock::new();
/// Safe wrapper around libc setlocale
fn set_time_locale() {
unsafe {
nix::libc::setlocale(nix::libc::LC_TIME, c"".as_ptr());
}
}
/// Safe wrapper around libc nl_langinfo that returns `Option<String>`
fn get_locale_info(item: nix::libc::nl_item) -> Option<String> {
unsafe {
let ptr = nix::libc::nl_langinfo(item);
if ptr.is_null() {
None
} else {
CStr::from_ptr(ptr).to_str().ok().map(String::from)
}
}
}
/// Internal function that performs the actual locale detection
fn detect_12_hour_format() -> bool {
// Helper function to check for 12-hour format indicators
fn has_12_hour_indicators(format_str: &str) -> bool {
const INDICATORS: &[&str] = &["%I", "%l", "%r"];
INDICATORS.iter().any(|&indicator| format_str.contains(indicator))
}
// Helper function to check for 24-hour format indicators
fn has_24_hour_indicators(format_str: &str) -> bool {
const INDICATORS: &[&str] = &["%H", "%k", "%R", "%T"];
INDICATORS.iter().any(|&indicator| format_str.contains(indicator))
}
// Set locale from environment variables (empty string = use LC_TIME/LANG env vars)
set_time_locale();
// Get locale format strings using safe wrappers
let d_t_fmt = get_locale_info(nix::libc::D_T_FMT);
let t_fmt_opt = get_locale_info(nix::libc::T_FMT);
let t_fmt_ampm_opt = get_locale_info(nix::libc::T_FMT_AMPM);
// Check D_T_FMT first
if let Some(ref format) = d_t_fmt {
// Check for 12-hour indicators first (higher priority)
if has_12_hour_indicators(format) {
return true;
}
// If we find 24-hour indicators, it's definitely not 12-hour
if has_24_hour_indicators(format) {
return false;
}
}
// Also check the time-only format as a fallback
if let Some(ref time_format) = t_fmt_opt {
if has_12_hour_indicators(time_format) {
return true;
}
}
// Check if there's a specific 12-hour format defined
if let Some(ref ampm_format) = t_fmt_ampm_opt {
// If T_FMT_AMPM is non-empty and different from T_FMT, locale supports 12-hour
if !ampm_format.is_empty() {
if let Some(ref time_format) = t_fmt_opt {
if ampm_format != time_format {
return true;
}
} else {
return true;
}
}
}
// Default to 24-hour format if we can't determine
false
}
}
cfg_langinfo! {
/// Detects whether the current locale prefers 12-hour or 24-hour time format
/// Results are cached for performance
pub fn uses_12_hour_format() -> bool {
*TIME_FORMAT_CACHE.get_or_init(detect_12_hour_format)
}
/// Cached default format string
static DEFAULT_FORMAT_CACHE: OnceLock<&'static str> = OnceLock::new();
/// Get the locale-appropriate default format string for date output
/// This respects the locale's preference for 12-hour vs 24-hour time
/// Results are cached for performance (following uucore patterns)
pub fn get_locale_default_format() -> &'static str {
DEFAULT_FORMAT_CACHE.get_or_init(|| {
if uses_12_hour_format() {
// Use 12-hour format with AM/PM
"%a %b %e %r %Z %Y"
} else {
// Use 24-hour format
"%a %b %e %X %Z %Y"
}
})
}
}
/// On platforms without nl_langinfo support, use 24-hour format by default
#[cfg(not(any(
target_os = "linux",
target_vendor = "apple",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd",
target_os = "dragonfly"
)))]
pub fn get_locale_default_format() -> &'static str {
"%a %b %e %X %Z %Y"
}
#[cfg(test)]
mod tests {
cfg_langinfo! {
use super::*;
#[test]
fn test_locale_detection() {
// Just verify the function doesn't panic
let _ = uses_12_hour_format();
let _ = get_locale_default_format();
}
#[test]
fn test_default_format_contains_valid_codes() {
let format = get_locale_default_format();
assert!(format.contains("%a")); // abbreviated weekday
assert!(format.contains("%b")); // abbreviated month
assert!(format.contains("%Y")); // year
assert!(format.contains("%Z")); // timezone
}
}
}

View file

@ -38,6 +38,10 @@ impl TruncateMode {
/// reduce by is greater than `fsize`, then this function returns
/// 0 (since it cannot return a negative number).
///
/// # Returns
///
/// `None` if rounding by 0, else the target size.
///
/// # Examples
///
/// Extending a file of 10 bytes by 5 bytes:
@ -45,7 +49,7 @@ impl TruncateMode {
/// ```rust,ignore
/// let mode = TruncateMode::Extend(5);
/// let fsize = 10;
/// assert_eq!(mode.to_size(fsize), 15);
/// assert_eq!(mode.to_size(fsize), Some(15));
/// ```
///
/// Reducing a file by more than its size results in 0:
@ -53,25 +57,36 @@ impl TruncateMode {
/// ```rust,ignore
/// let mode = TruncateMode::Reduce(5);
/// let fsize = 3;
/// assert_eq!(mode.to_size(fsize), 0);
/// assert_eq!(mode.to_size(fsize), Some(0));
/// ```
fn to_size(&self, fsize: u64) -> u64 {
///
/// Rounding a file by 0:
///
/// ```rust,ignore
/// let mode = TruncateMode::RoundDown(0);
/// let fsize = 17;
/// assert_eq!(mode.to_size(fsize), None);
/// ```
fn to_size(&self, fsize: u64) -> Option<u64> {
match self {
Self::Absolute(size) => *size,
Self::Extend(size) => fsize + size,
Self::Reduce(size) => {
if *size > fsize {
0
} else {
fsize - size
}
}
Self::AtMost(size) => fsize.min(*size),
Self::AtLeast(size) => fsize.max(*size),
Self::RoundDown(size) => fsize - fsize % size,
Self::RoundUp(size) => fsize + fsize % size,
Self::Absolute(size) => Some(*size),
Self::Extend(size) => Some(fsize + size),
Self::Reduce(size) => Some(fsize.saturating_sub(*size)),
Self::AtMost(size) => Some(fsize.min(*size)),
Self::AtLeast(size) => Some(fsize.max(*size)),
Self::RoundDown(size) => fsize.checked_rem(*size).map(|remainder| fsize - remainder),
Self::RoundUp(size) => fsize.checked_next_multiple_of(*size),
}
}
/// Determine if mode is absolute
///
/// # Returns
///
/// `true` is self matches Self::Absolute(_), `false` otherwise.
fn is_absolute(&self) -> bool {
matches!(self, Self::Absolute(_))
}
}
pub mod options {
@ -170,18 +185,9 @@ pub fn uu_app() -> Command {
///
/// If the file could not be opened, or there was a problem setting the
/// size of the file.
fn file_truncate(filename: &OsString, create: bool, size: u64) -> UResult<()> {
fn do_file_truncate(filename: &Path, create: bool, size: u64) -> UResult<()> {
let path = Path::new(filename);
#[cfg(unix)]
if let Ok(metadata) = metadata(path) {
if metadata.file_type().is_fifo() {
return Err(USimpleError::new(
1,
translate!("truncate-error-cannot-open-no-device", "filename" => filename.to_string_lossy().quote()),
));
}
}
match OpenOptions::new().write(true).create(create).open(path) {
Ok(file) => file.set_len(size),
Err(e) if e.kind() == ErrorKind::NotFound && !create => Ok(()),
@ -192,155 +198,44 @@ fn file_truncate(filename: &OsString, create: bool, size: u64) -> UResult<()> {
)
}
/// Truncate files to a size relative to a given file.
///
/// `rfilename` is the name of the reference file.
///
/// `size_string` gives the size relative to the reference file to which
/// to set the target files. For example, "+3K" means "set each file to
/// be three kilobytes larger than the size of the reference file".
///
/// If `create` is true, then each file will be created if it does not
/// already exist.
///
/// # Errors
///
/// If any file could not be opened, or there was a problem setting
/// the size of at least one file.
///
/// If at least one file is a named pipe (also known as a fifo).
fn truncate_reference_and_size(
rfilename: &str,
size_string: &str,
filenames: &[OsString],
create: bool,
fn file_truncate(
no_create: bool,
reference_size: Option<u64>,
mode: &TruncateMode,
filename: &OsString,
) -> UResult<()> {
let mode = match parse_mode_and_size(size_string) {
Err(e) => {
return Err(USimpleError::new(
1,
translate!("truncate-error-invalid-number", "error" => e),
));
let path = Path::new(filename);
// Get the length of the file.
let file_size = match metadata(path) {
Ok(metadata) => {
// A pipe has no length. Do this check here to avoid duplicate `stat()` syscall.
#[cfg(unix)]
if metadata.file_type().is_fifo() {
return Err(USimpleError::new(
1,
translate!("truncate-error-cannot-open-no-device", "filename" => filename.to_string_lossy().quote()),
));
}
metadata.len()
}
Ok(TruncateMode::Absolute(_)) => {
return Err(USimpleError::new(
1,
translate!("truncate-error-must-specify-relative-size"),
));
}
Ok(m) => m,
Err(_) => 0,
};
if let TruncateMode::RoundDown(0) | TruncateMode::RoundUp(0) = mode {
// The reference size can be either:
//
// 1. The size of a given file
// 2. The size of the file to be truncated if no reference has been provided.
let actual_reference_size = reference_size.unwrap_or(file_size);
let Some(truncate_size) = mode.to_size(actual_reference_size) else {
return Err(USimpleError::new(
1,
translate!("truncate-error-division-by-zero"),
));
}
};
let metadata = metadata(rfilename).map_err(|e| match e.kind() {
ErrorKind::NotFound => USimpleError::new(
1,
translate!("truncate-error-cannot-stat-no-such-file", "filename" => rfilename.quote()),
),
_ => e.map_err_context(String::new),
})?;
let fsize = metadata.len();
let tsize = mode.to_size(fsize);
for filename in filenames {
file_truncate(filename, create, tsize)?;
}
Ok(())
}
/// Truncate files to match the size of a given reference file.
///
/// `rfilename` is the name of the reference file.
///
/// If `create` is true, then each file will be created if it does not
/// already exist.
///
/// # Errors
///
/// If any file could not be opened, or there was a problem setting
/// the size of at least one file.
///
/// If at least one file is a named pipe (also known as a fifo).
fn truncate_reference_file_only(
rfilename: &str,
filenames: &[OsString],
create: bool,
) -> UResult<()> {
let metadata = metadata(rfilename).map_err(|e| match e.kind() {
ErrorKind::NotFound => USimpleError::new(
1,
translate!("truncate-error-cannot-stat-no-such-file", "filename" => rfilename.quote()),
),
_ => e.map_err_context(String::new),
})?;
let tsize = metadata.len();
for filename in filenames {
file_truncate(filename, create, tsize)?;
}
Ok(())
}
/// Truncate files to a specified size.
///
/// `size_string` gives either an absolute size or a relative size. A
/// relative size adjusts the size of each file relative to its current
/// size. For example, "3K" means "set each file to be three kilobytes"
/// whereas "+3K" means "set each file to be three kilobytes larger than
/// its current size".
///
/// If `create` is true, then each file will be created if it does not
/// already exist.
///
/// # Errors
///
/// If any file could not be opened, or there was a problem setting
/// the size of at least one file.
///
/// If at least one file is a named pipe (also known as a fifo).
fn truncate_size_only(size_string: &str, filenames: &[OsString], create: bool) -> UResult<()> {
let mode = parse_mode_and_size(size_string).map_err(|e| {
USimpleError::new(1, translate!("truncate-error-invalid-number", "error" => e))
})?;
if let TruncateMode::RoundDown(0) | TruncateMode::RoundUp(0) = mode {
return Err(USimpleError::new(
1,
translate!("truncate-error-division-by-zero"),
));
}
for filename in filenames {
let path = Path::new(filename);
let fsize = match metadata(path) {
Ok(m) => {
#[cfg(unix)]
if m.file_type().is_fifo() {
return Err(USimpleError::new(
1,
translate!("truncate-error-cannot-open-no-device", "filename" => filename.to_string_lossy().quote()),
));
}
m.len()
}
Err(_) => 0,
};
let tsize = mode.to_size(fsize);
// TODO: Fix duplicate call to stat
file_truncate(filename, create, tsize)?;
}
Ok(())
do_file_truncate(path, !no_create, truncate_size)
}
fn truncate(
@ -350,21 +245,50 @@ fn truncate(
size: Option<String>,
filenames: &[OsString],
) -> UResult<()> {
let create = !no_create;
let reference_size = match reference {
Some(reference_path) => {
let reference_metadata = metadata(&reference_path).map_err(|error| match error.kind() {
ErrorKind::NotFound => USimpleError::new(
1,
translate!("truncate-error-cannot-stat-no-such-file", "filename" => reference_path.quote()),
),
_ => error.map_err_context(String::new),
})?;
// There are four possibilities
// - reference file given and size given,
// - reference file given but no size given,
// - no reference file given but size given,
// - no reference file given and no size given,
match (reference, size) {
(Some(rfilename), Some(size_string)) => {
truncate_reference_and_size(&rfilename, &size_string, filenames, create)
Some(reference_metadata.len())
}
(Some(rfilename), None) => truncate_reference_file_only(&rfilename, filenames, create),
(None, Some(size_string)) => truncate_size_only(&size_string, filenames, create),
(None, None) => unreachable!(), // this case cannot happen anymore because it's handled by clap
None => None,
};
let size_string = size.as_deref();
// Omitting the mode is equivalent to extending a file by 0 bytes.
let mode = match size_string {
Some(string) => match parse_mode_and_size(string) {
Err(error) => {
return Err(USimpleError::new(
1,
translate!("truncate-error-invalid-number", "error" => error),
));
}
Ok(mode) => mode,
},
None => TruncateMode::Extend(0),
};
// If a reference file has been given, the truncate mode cannot be absolute.
if reference_size.is_some() && mode.is_absolute() {
return Err(USimpleError::new(
1,
translate!("truncate-error-must-specify-relative-size"),
));
}
for filename in filenames {
file_truncate(no_create, reference_size, &mode, filename)?;
}
Ok(())
}
/// Decide whether a character is one of the size modifiers, like '+' or '<'.
@ -382,13 +306,12 @@ fn is_modifier(c: char) -> bool {
///
/// # Panics
///
/// If `size_string` is empty, or if no number could be parsed from the
/// given string (for example, if the string were `"abc"`).
/// If `size_string` is empty.
///
/// # Examples
///
/// ```rust,ignore
/// assert_eq!(parse_mode_and_size("+123"), (TruncateMode::Extend, 123));
/// assert_eq!(parse_mode_and_size("+123"), Ok(TruncateMode::Extend(123)));
/// ```
fn parse_mode_and_size(size_string: &str) -> Result<TruncateMode, ParseSizeError> {
// Trim any whitespace.
@ -432,8 +355,13 @@ mod tests {
#[test]
fn test_to_size() {
assert_eq!(TruncateMode::Extend(5).to_size(10), 15);
assert_eq!(TruncateMode::Reduce(5).to_size(10), 5);
assert_eq!(TruncateMode::Reduce(5).to_size(3), 0);
assert_eq!(TruncateMode::Extend(5).to_size(10), Some(15));
assert_eq!(TruncateMode::Reduce(5).to_size(10), Some(5));
assert_eq!(TruncateMode::Reduce(5).to_size(3), Some(0));
assert_eq!(TruncateMode::RoundDown(4).to_size(13), Some(12));
assert_eq!(TruncateMode::RoundDown(4).to_size(16), Some(16));
assert_eq!(TruncateMode::RoundUp(8).to_size(10), Some(16));
assert_eq!(TruncateMode::RoundUp(8).to_size(16), Some(16));
assert_eq!(TruncateMode::RoundDown(0).to_size(123), None);
}
}

View file

@ -1092,3 +1092,58 @@ fn test_date_military_timezone_with_offset_variations() {
.stdout_is(format!("{expected}\n"));
}
}
// Locale-aware hour formatting tests
#[test]
#[cfg(unix)]
fn test_date_locale_hour_c_locale() {
// C locale should use 24-hour format
new_ucmd!()
.env("LC_ALL", "C")
.env("TZ", "UTC")
.arg("-d")
.arg("2025-10-11T13:00")
.succeeds()
.stdout_contains("13:00");
}
#[test]
#[cfg(any(
target_os = "linux",
target_vendor = "apple",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd",
target_os = "dragonfly"
))]
fn test_date_locale_hour_en_us() {
// en_US locale typically uses 12-hour format when available
// Note: If locale is not installed on system, falls back to C locale (24-hour)
let result = new_ucmd!()
.env("LC_ALL", "en_US.UTF-8")
.env("TZ", "UTC")
.arg("-d")
.arg("2025-10-11T13:00")
.succeeds();
let stdout = result.stdout_str();
// Accept either 12-hour (if locale available) or 24-hour (if locale unavailable)
// The important part is that the code doesn't crash and handles locale detection gracefully
assert!(
stdout.contains("1:00") || stdout.contains("13:00"),
"date output should contain either 1:00 (12-hour) or 13:00 (24-hour), got: {stdout}"
);
}
#[test]
fn test_date_explicit_format_overrides_locale() {
// Explicit format should override locale preferences
new_ucmd!()
.env("LC_ALL", "en_US.UTF-8")
.env("TZ", "UTC")
.arg("-d")
.arg("2025-10-11T13:00")
.arg("+%H:%M")
.succeeds()
.stdout_is("13:00\n");
}

View file

@ -33,18 +33,13 @@ path_GNU="$("${READLINK}" -fm -- "${path_GNU:-${path_UUTILS}/../gnu}")"
###
release_tag_GNU="v9.9"
# check if the GNU coreutils has been cloned, if not print instructions
# note: the ${path_GNU} might already exist, so we check for the .git directory
if test ! -d "${path_GNU}/.git"; then
# note: the ${path_GNU} might already exist, so we check for the configure
if test ! -f "${path_GNU}/configure"; then
echo "Could not find the GNU coreutils (expected at '${path_GNU}')"
echo "Download them to the expected path:"
echo " git clone --recurse-submodules https://github.com/coreutils/coreutils.git \"${path_GNU}\""
echo "Afterwards, checkout the latest release tag:"
echo " cd \"${path_GNU}\""
echo " git fetch --all --tags"
echo " git checkout tags/${release_tag_GNU}"
echo " (cd '${path_GNU}' && fetch-gnu.sh ) "
echo "You can edit fetch-gnu.sh to change the tag"
exit 1
fi
@ -125,23 +120,24 @@ done
if test -f gnu-built; then
echo "GNU build already found. Skip"
echo "'rm -f $(pwd)/gnu-built' to force the build"
echo "'rm -f $(pwd)/{gnu-built,src/getlimits}' to force the build"
echo "Note: the customization of the tests will still happen"
else
# Disable useless checks
"${SED}" -i 's|check-texinfo: $(syntax_checks)|check-texinfo:|' doc/local.mk
"${SED}" -i '/^wget.*/d' bootstrap.conf # wget is used to DL po. Remove the dep.
./bootstrap --skip-po
# Use CFLAGS for best build time since we discard GNU coreutils
CFLAGS="${CFLAGS} -pipe -O0 -s" ./configure --quiet --disable-gcc-warnings --disable-nls --disable-dependency-tracking --disable-bold-man-page-references \
CFLAGS="${CFLAGS} -pipe -O0 -s" ./configure -C --quiet --disable-gcc-warnings --disable-nls --disable-dependency-tracking --disable-bold-man-page-references \
--enable-single-binary=symlinks \
"$([ "${SELINUX_ENABLED}" = 1 ] && echo --with-selinux || echo --without-selinux)"
#Add timeout to to protect against hangs
"${SED}" -i 's|^"\$@|'"${SYSTEM_TIMEOUT}"' 600 "\$@|' build-aux/test-driver
# Use a better diff
"${SED}" -i 's|diff -c|diff -u|g' tests/Coreutils.pm
# Skip make if possible
# Use our nproc for *BSD and macOS
"${MAKE}" -j "$("${UU_BUILD_DIR}/nproc")"
test -f src/getlimits || "${MAKE}" -j "$("${UU_BUILD_DIR}/nproc")"
cp -f src/getlimits "${UU_BUILD_DIR}"
# Handle generated factor tests
t_first=00
@ -175,9 +171,6 @@ grep -rl '\$abs_path_dir_' tests/*/*.sh | xargs -r "${SED}" -i "s|\$abs_path_dir
# Different message
"${SED}" -i "s|coreutils: unknown program 'blah'|blah: function/utility not found|" tests/misc/coreutils.sh
# Remove hfs dependency (should be merged to upstream)
"${SED}" -i -e "s|hfsplus|ext4 -O casefold|" -e "s|cd mnt|rm -d mnt/lost+found;chattr +F mnt;cd mnt|" tests/mv/hardlink-case.sh
# Use the system coreutils where the test fails due to error in a util that is not the one being tested
"${SED}" -i "s|grep '^#define HAVE_CAP 1' \$CONFIG_HEADER > /dev/null|true|" tests/ls/capability.sh
@ -229,8 +222,6 @@ sed -i -e "s|---dis ||g" tests/tail/overlay-headers.sh
-e "s|strace -e inotify_add_watch|strace -f -e inotify_add_watch|" \
tests/tail/inotify-dir-recreate.sh
test -f "${UU_BUILD_DIR}/getlimits" || cp src/getlimits "${UU_BUILD_DIR}"
# pr produces very long log and this command isn't super interesting
# SKIP for now
"${SED}" -i -e "s|my \$prog = 'pr';$|my \$prog = 'pr';CuSkip::skip \"\$prog: SKIP for producing too long logs\";|" tests/pr/pr-tests.pl

9
util/fetch-gnu.sh Executable file
View file

@ -0,0 +1,9 @@
#!/bin/bash -e
ver="9.9"
repo=https://github.com/coreutils/coreutils
curl -L "${repo}/releases/download/v${ver}/coreutils-${ver}.tar.xz" | tar --strip-components=1 -xJf -
# backport from coreutils > 9.9
curl ${repo}/raw/refs/heads/master/tests/mv/hardlink-case.sh > tests/mv/hardlink-case.sh
curl ${repo}/raw/refs/heads/master/tests/mkdir/writable-under-readonly.sh > tests/mkdir/writable-under-readonly.sh
curl ${repo}/raw/refs/heads/master/tests/cp/cp-mv-enotsup-xattr.sh > tests/cp/cp-mv-enotsup-xattr.sh #spell-checker:disable-line

View file

@ -31,5 +31,3 @@
= Disabled. Enabled at GNU coreutils > 9.9 =
* tests/misc/tac-continue.sh
* tests/mkdir/writable-under-readonly.sh
* tests/cp/cp-mv-enotsup-xattr.sh