From 6c5145c8559fe93452276528d52c79ceeb41e082 Mon Sep 17 00:00:00 2001 From: "Guillem L. Jara" <4lon3ly0@tutanota.com> Date: Wed, 29 Oct 2025 14:46:00 +0100 Subject: [PATCH 1/6] fix(stat): constrain mount point fetching Fixes issue #9072, where some AppArmor profiles designed for GNU coreutils would break under uutils because we would fetch this info unnecessarily. --- src/uu/stat/src/stat.rs | 49 +++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index 327e89a6888..8a304485e69 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -908,6 +908,28 @@ impl Stater { Ok(tokens) } + fn populate_mount_list() -> UResult> { + let mut mount_list = read_fs_list() + .map_err(|e| { + USimpleError::new( + e.code(), + StatError::CannotReadFilesystem { + error: e.to_string(), + } + .to_string(), + ) + })? + .iter() + .map(|mi| mi.mount_dir.clone()) + .collect::>(); + + // Reverse sort. The longer comes first. + mount_list.sort(); + mount_list.reverse(); + + Ok(mount_list) + } + fn new(matches: &ArgMatches) -> UResult { let files: Vec = matches .get_many::(options::FILES) @@ -938,27 +960,16 @@ impl Stater { let default_dev_tokens = Self::generate_tokens(&Self::default_format(show_fs, terse, true), use_printf)?; - let mount_list = if show_fs { - // mount points aren't displayed when showing filesystem information + // mount points aren't displayed when showing filesystem information, or + // whenever the format string does not request the mount point. + let mount_list = if show_fs + || !default_tokens + .iter() + .any(|tok| matches!(tok, Token::Directive { format: 'm', .. })) + { None } else { - let mut mount_list = read_fs_list() - .map_err(|e| { - USimpleError::new( - e.code(), - StatError::CannotReadFilesystem { - error: e.to_string(), - } - .to_string(), - ) - })? - .iter() - .map(|mi| mi.mount_dir.clone()) - .collect::>(); - // Reverse sort. The longer comes first. - mount_list.sort(); - mount_list.reverse(); - Some(mount_list) + Some(Self::populate_mount_list()?) }; Ok(Self { From da819b792d143a6c0ef214cfabfd87311929d1f0 Mon Sep 17 00:00:00 2001 From: "Guillem L. Jara" <4lon3ly0@tutanota.com> Date: Wed, 29 Oct 2025 15:11:35 +0100 Subject: [PATCH 2/6] fix(stat): fix default opts and several format errors Fixes issue #9071, where the default options for the `Device` field where incorrect, as well as several format flags that were not working as intended: %R, %Hr, %Lr, %Hd, %Ld, %t. --- src/uu/stat/src/stat.rs | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index 8a304485e69..b8ffb61b970 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -70,6 +70,8 @@ struct Flags { space: bool, sign: bool, group: bool, + major: bool, + minor: bool, } /// checks if the string is within the specified bound, @@ -299,6 +301,16 @@ fn group_num(s: &str) -> Cow<'_, str> { res.into() } +/// Keeps major part of an integer +fn major(n: u64) -> u64 { + (n >> 8) & 0xFF +} + +// Keeps minor part of an integer +fn minor(n: u64) -> u64 { + n & 0xFF +} + struct Stater { follow: bool, show_fs: bool, @@ -794,13 +806,14 @@ impl Stater { if let Some(&next_char) = chars.get(*i + 1) { if (chars[*i] == 'H' || chars[*i] == 'L') && (next_char == 'd' || next_char == 'r') { - let specifier = format!("{}{next_char}", chars[*i]); + flag.major = chars[*i] == 'H'; + flag.minor = chars[*i] == 'L'; *i += 1; return Ok(Token::Directive { flag, width, precision, - format: specifier.chars().next().unwrap(), + format: next_char, }); } } @@ -1063,6 +1076,8 @@ impl Stater { } } // device number in decimal + 'd' if flag.major => OutputType::Unsigned(major(meta.dev())), + 'd' if flag.minor => OutputType::Unsigned(minor(meta.dev())), 'd' => OutputType::Unsigned(meta.dev()), // device number in hex 'D' => OutputType::UnsignedHex(meta.dev()), @@ -1101,10 +1116,10 @@ impl Stater { 's' => OutputType::Integer(meta.len() as i64), // major device type in hex, for character/block device special // files - 't' => OutputType::UnsignedHex(meta.rdev() >> 8), + 't' => OutputType::UnsignedHex(major(meta.rdev())), // minor device type in hex, for character/block device special // files - 'T' => OutputType::UnsignedHex(meta.rdev() & 0xff), + 'T' => OutputType::UnsignedHex(minor(meta.rdev())), // user ID of owner 'u' => OutputType::Unsigned(meta.uid() as u64), // user name of owner @@ -1147,15 +1162,10 @@ impl Stater { .map_or((0, 0), system_time_to_sec); OutputType::Float(sec as f64 + nsec as f64 / 1_000_000_000.0) } - 'R' => { - let major = meta.rdev() >> 8; - let minor = meta.rdev() & 0xff; - OutputType::Str(format!("{major},{minor}")) - } + 'R' => OutputType::UnsignedHex(meta.rdev()), + 'r' if flag.major => OutputType::Unsigned(major(meta.rdev())), + 'r' if flag.minor => OutputType::Unsigned(minor(meta.rdev())), 'r' => OutputType::Unsigned(meta.rdev()), - 'H' => OutputType::Unsigned(meta.rdev() >> 8), // Major in decimal - 'L' => OutputType::Unsigned(meta.rdev() & 0xff), // Minor in decimal - _ => OutputType::Unknown, }; print_it(&output, flag, width, precision); @@ -1280,7 +1290,7 @@ impl Stater { } else { let device_line = if show_dev_type { format!( - "{}: %Dh/%dd\t{}: %-10i {}: %-5h {} {}: %t,%T\n", + "{}: %Hd,%Ld\t{}: %-10i {}: %-5h {} {}: %t,%T\n", translate!("stat-word-device"), translate!("stat-word-inode"), translate!("stat-word-links"), @@ -1289,7 +1299,7 @@ impl Stater { ) } else { format!( - "{}: %Dh/%dd\t{}: %-10i {}: %h\n", + "{}: %Hd,%Ld\t{}: %-10i {}: %h\n", translate!("stat-word-device"), translate!("stat-word-inode"), translate!("stat-word-links") From fa9dcd5d320666d8bcaa0fb368be5a7b96c4cb10 Mon Sep 17 00:00:00 2001 From: "Guillem L. Jara" <4lon3ly0@tutanota.com> Date: Wed, 29 Oct 2025 15:36:47 +0100 Subject: [PATCH 3/6] fix(stat): fix % escaping Previosuly, stat would ignore the next char after escaping it; e.g., "%%m" would become "%" instead of literally "%m". --- src/uu/stat/src/stat.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index b8ffb61b970..aa670ff097c 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -751,7 +751,6 @@ impl Stater { return Ok(Token::Char('%')); } if chars[*i] == '%' { - *i += 1; return Ok(Token::Char('%')); } From 7c489120dc1262181fb9cfac8dd3f3e30be9d743 Mon Sep 17 00:00:00 2001 From: "Guillem L. Jara" <4lon3ly0@tutanota.com> Date: Wed, 29 Oct 2025 19:55:32 +0100 Subject: [PATCH 4/6] chore(stat): Add test case for percent escaping --- tests/by-util/test_stat.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/by-util/test_stat.rs b/tests/by-util/test_stat.rs index 0aad7361bb8..eafa3ce4943 100644 --- a/tests/by-util/test_stat.rs +++ b/tests/by-util/test_stat.rs @@ -567,3 +567,14 @@ fn test_mount_point_combined_with_other_specifiers() { "Should print mount point, file name, and size" ); } + +#[cfg(unix)] +#[test] +fn test_percent_escaping() { + let ts = TestScenario::new(util_name!()); + let result = ts + .ucmd() + .args(&["--printf", "%%%m%%m%m%%%", "/bin/sh"]) + .succeeds(); + assert_eq!(result.stdout_str(), "%/%m/%%"); +} From c30593a40a4bed0990deb336ec2fd66dbf551bd4 Mon Sep 17 00:00:00 2001 From: "Guillem L. Jara" <4lon3ly0@tutanota.com> Date: Wed, 31 Dec 2025 09:28:11 +0100 Subject: [PATCH 5/6] fix(stat, mknod): replace custom (flawed in stat) logic with libc's. Now uucore::fs reexports libc's major(), minor() and makedev() directives. --- .../cspell.dictionaries/jargon.wordlist.txt | 1 + src/uu/mknod/Cargo.toml | 2 +- src/uu/mknod/src/mknod.rs | 9 ++----- src/uu/stat/src/stat.rs | 24 ++++++------------- src/uucore/src/lib/features/fs.rs | 20 ++++++++++++++++ 5 files changed, 31 insertions(+), 25 deletions(-) diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index 9fa0b625aac..a1bda0e7690 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -86,6 +86,7 @@ listxattr llistxattr lossily lstat +makedev mebi mebibytes mergeable diff --git a/src/uu/mknod/Cargo.toml b/src/uu/mknod/Cargo.toml index 50e7e2fce3c..32b983134e3 100644 --- a/src/uu/mknod/Cargo.toml +++ b/src/uu/mknod/Cargo.toml @@ -21,7 +21,7 @@ path = "src/mknod.rs" [dependencies] clap = { workspace = true } libc = { workspace = true } -uucore = { workspace = true, features = ["mode"] } +uucore = { workspace = true, features = ["mode", "fs"] } fluent = { workspace = true } [features] diff --git a/src/uu/mknod/src/mknod.rs b/src/uu/mknod/src/mknod.rs index cc22aee5f5c..8a4cf82d01e 100644 --- a/src/uu/mknod/src/mknod.rs +++ b/src/uu/mknod/src/mknod.rs @@ -13,6 +13,7 @@ use std::ffi::CString; use uucore::display::Quotable; use uucore::error::{UResult, USimpleError, UUsageError, set_exit_code}; use uucore::format_usage; +use uucore::fs::makedev; use uucore::translate; const MODE_RW_UGO: mode_t = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH; @@ -26,12 +27,6 @@ mod options { pub const CONTEXT: &str = "context"; } -#[inline(always)] -fn makedev(maj: u64, min: u64) -> dev_t { - // pick up from - ((min & 0xff) | ((maj & 0xfff) << 8) | ((min & !0xff) << 12) | ((maj & !0xfff) << 32)) as dev_t -} - #[derive(Clone, PartialEq)] enum FileType { Block, @@ -145,7 +140,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { translate!("mknod-error-fifo-no-major-minor"), )); } - (_, Some(&major), Some(&minor)) => makedev(major, minor), + (_, Some(&major), Some(&minor)) => makedev(major as _, minor as _), _ => { return Err(UUsageError::new( 1, diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index aa670ff097c..bae89461cb8 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -9,7 +9,7 @@ use uucore::translate; use clap::builder::ValueParser; use uucore::display::Quotable; -use uucore::fs::display_permissions; +use uucore::fs::{display_permissions, major, minor}; use uucore::fsext::{ FsMeta, MetadataTimeField, StatFs, metadata_get_time, pretty_filetype, pretty_fstype, read_fs_list, statfs, @@ -301,16 +301,6 @@ fn group_num(s: &str) -> Cow<'_, str> { res.into() } -/// Keeps major part of an integer -fn major(n: u64) -> u64 { - (n >> 8) & 0xFF -} - -// Keeps minor part of an integer -fn minor(n: u64) -> u64 { - n & 0xFF -} - struct Stater { follow: bool, show_fs: bool, @@ -1075,8 +1065,8 @@ impl Stater { } } // device number in decimal - 'd' if flag.major => OutputType::Unsigned(major(meta.dev())), - 'd' if flag.minor => OutputType::Unsigned(minor(meta.dev())), + 'd' if flag.major => OutputType::Unsigned(major(meta.dev() as _) as u64), + 'd' if flag.minor => OutputType::Unsigned(minor(meta.dev() as _) as u64), 'd' => OutputType::Unsigned(meta.dev()), // device number in hex 'D' => OutputType::UnsignedHex(meta.dev()), @@ -1115,10 +1105,10 @@ impl Stater { 's' => OutputType::Integer(meta.len() as i64), // major device type in hex, for character/block device special // files - 't' => OutputType::UnsignedHex(major(meta.rdev())), + 't' => OutputType::UnsignedHex(major(meta.rdev() as _) as u64), // minor device type in hex, for character/block device special // files - 'T' => OutputType::UnsignedHex(minor(meta.rdev())), + 'T' => OutputType::UnsignedHex(minor(meta.rdev() as _) as u64), // user ID of owner 'u' => OutputType::Unsigned(meta.uid() as u64), // user name of owner @@ -1162,8 +1152,8 @@ impl Stater { OutputType::Float(sec as f64 + nsec as f64 / 1_000_000_000.0) } 'R' => OutputType::UnsignedHex(meta.rdev()), - 'r' if flag.major => OutputType::Unsigned(major(meta.rdev())), - 'r' if flag.minor => OutputType::Unsigned(minor(meta.rdev())), + 'r' if flag.major => OutputType::Unsigned(major(meta.rdev() as _) as u64), + 'r' if flag.minor => OutputType::Unsigned(minor(meta.rdev() as _) as u64), 'r' => OutputType::Unsigned(meta.rdev()), _ => OutputType::Unknown, }; diff --git a/src/uucore/src/lib/features/fs.rs b/src/uucore/src/lib/features/fs.rs index 16de054a3c2..9c710858082 100644 --- a/src/uucore/src/lib/features/fs.rs +++ b/src/uucore/src/lib/features/fs.rs @@ -13,6 +13,8 @@ use libc::{ S_IRUSR, S_ISGID, S_ISUID, S_ISVTX, S_IWGRP, S_IWOTH, S_IWUSR, S_IXGRP, S_IXOTH, S_IXUSR, mkfifo, mode_t, }; +#[cfg(all(unix, not(target_os = "redox")))] +pub use libc::{major, makedev, minor}; use std::collections::HashSet; use std::collections::VecDeque; use std::env; @@ -839,6 +841,24 @@ pub fn make_fifo(path: &Path) -> std::io::Result<()> { } } +// Redox's libc appears not to include the following utilities + +#[cfg(target_os = "redox")] +pub fn major(dev: libc::dev_t) -> libc::c_uint { + (((dev >> 8) & 0xFFF) | ((dev >> 32) & 0xFFFFF000)) as _ +} + +#[cfg(target_os = "redox")] +pub fn minor(dev: libc::dev_t) -> libc::c_uint { + ((dev & 0xFF) | ((dev >> 12) & 0xFFFFF00)) as _ +} + +#[cfg(target_os = "redox")] +pub fn makedev(maj: libc::c_uint, min: libc::c_uint) -> libc::dev_t { + let [maj, min] = [maj as libc::dev_t, min as libc::dev_t]; + (min & 0xff) | ((maj & 0xfff) << 8) | ((min & !0xff) << 12) | ((maj & !0xfff) << 32) +} + #[cfg(test)] mod tests { // Note this useful idiom: importing names from outer (for mod tests) scope. From 613e9be6bb0bcf3b07f2b0a8d9ce68fd9eb73001 Mon Sep 17 00:00:00 2001 From: "Guillem L. Jara" <4lon3ly0@tutanota.com> Date: Wed, 31 Dec 2025 20:29:57 +0100 Subject: [PATCH 6/6] chore(stat): add test case for file metadata --- tests/by-util/test_stat.rs | 54 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/by-util/test_stat.rs b/tests/by-util/test_stat.rs index eafa3ce4943..8347d49c795 100644 --- a/tests/by-util/test_stat.rs +++ b/tests/by-util/test_stat.rs @@ -9,6 +9,9 @@ use uutests::unwrap_or_return; use uutests::util::{TestScenario, expected_result}; use uutests::util_name; +use std::fs::metadata; +use std::os::unix::fs::MetadataExt; + #[test] fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); @@ -578,3 +581,54 @@ fn test_percent_escaping() { .succeeds(); assert_eq!(result.stdout_str(), "%/%m/%%"); } + +#[cfg(unix)] +#[test] +fn test_correct_metadata() { + use uucore::fs::{major, minor}; + + let ts = TestScenario::new(util_name!()); + let parse = |(i, str): (usize, &str)| { + // Some outputs (%[fDRtT]) are in hex; they're redundant, but we might + // as well also test case conversion. + let radix = if matches!(i, 2 | 10 | 14..) { 16 } else { 10 }; + i128::from_str_radix(str, radix) + }; + for device in ["/", "/dev/null"] { + let metadata = metadata(device).unwrap(); + // We avoid time vals because of fs race conditions, especially with + // access time and status time (this previously killed an otherwise + // perfect 11-hour-long CI run...). The large number of as-casts is + // due to inconsistencies on some platforms (read: BSDs), and we use + // i128 as a lowest-common denominator. + let test_str = "%u %g %f %b %s %h %i %d %Hd %Ld %D %r %Hr %Lr %R %t %T"; + let expected = [ + metadata.uid() as _, + metadata.gid() as _, + metadata.mode() as _, + metadata.blocks() as _, + metadata.size() as _, + metadata.nlink() as _, + metadata.ino() as _, + metadata.dev() as _, + major(metadata.dev() as _) as _, + minor(metadata.dev() as _) as _, + metadata.dev() as _, + metadata.rdev() as _, + major(metadata.rdev() as _) as _, + minor(metadata.rdev() as _) as _, + metadata.rdev() as _, + major(metadata.rdev() as _) as _, + minor(metadata.rdev() as _) as _, + ]; + let result = ts.ucmd().args(&["--printf", test_str, device]).succeeds(); + let output = result + .stdout_str() + .split(' ') + .enumerate() + .map(parse) + .collect::, _>>() + .unwrap(); + assert_eq!(output, &expected); + } +}