diff --git a/.github/workflows/l10n.yml b/.github/workflows/l10n.yml index 30b2cd5b17e..57c4d51ef21 100644 --- a/.github/workflows/l10n.yml +++ b/.github/workflows/l10n.yml @@ -62,6 +62,11 @@ jobs: brew install coreutils ;; esac + - name: Build with platform features + shell: bash + run: | + ## Build with platform-specific features to enable l10n functionality + cargo build --features ${{ matrix.job.features }} - name: Test l10n functionality shell: bash run: | @@ -144,6 +149,10 @@ jobs: sudo apt-get -y update ; sudo apt-get -y install libselinux1-dev sudo locale-gen --keep-existing fr_FR.UTF-8 locale -a | grep -i fr || exit 1 + - name: Build coreutils with clap localization support + shell: bash + run: | + cargo build --features feat_os_unix --bin coreutils - name: Test English clap error localization shell: bash run: | @@ -312,6 +321,11 @@ jobs: ## Generate French locale for testing sudo locale-gen --keep-existing fr_FR.UTF-8 locale -a | grep -i fr || echo "French locale not found, continuing anyway" + - name: Build coreutils with l10n support + shell: bash + run: | + ## Build coreutils with Unix features and l10n support + cargo build --features feat_os_unix --bin coreutils - name: Test French localization shell: bash run: | @@ -692,7 +706,7 @@ jobs: mkdir -p "$CARGO_INSTALL_DIR" # Install using cargo with l10n features - cargo install --path . --features "ls,cat,touch" --root "$CARGO_INSTALL_DIR" --locked + cargo install --path . --features ${{ matrix.job.features }} --root "$CARGO_INSTALL_DIR" --locked # Verify installation echo "Testing cargo-installed binaries..." @@ -1365,3 +1379,11 @@ jobs: echo "::warning::More locales than expected ($total_match_count entries)" echo "This might be expected for utility + uucore locales" fi + + l10n_locale_embedding_regression_test: + name: L10n/Locale Embedding Regression Test + runs-on: ubuntu-latest + needs: [l10n_locale_embedding_cat, l10n_locale_embedding_ls, l10n_locale_embedding_multicall, l10n_locale_embedding_cargo_install] + steps: + - name: All locale embedding tests passed + run: echo "✓ All locale embedding tests passed successfully" diff --git a/.github/workflows/make.yml b/.github/workflows/make.yml index fb1ed4bd9c6..7fc96461216 100644 --- a/.github/workflows/make.yml +++ b/.github/workflows/make.yml @@ -266,40 +266,6 @@ jobs: # 2. the makefile doesn't try to install libstdbuf even though stdbuf is skipped DESTDIR=/tmp/ make SKIP_UTILS="stdbuf" install - # keep this job minimal to avoid have many duplicated build with CICD - build_makefile-other: - name: Build/Makefile - runs-on: ${{ matrix.job.os }} - env: - CARGO_INCREMENTAL: 0 - strategy: - fail-fast: false - matrix: - job: - - { os: windows-latest , features: feat_os_windows } - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - uses: Swatinem/rust-cache@v2 - - name: Run sccache-cache - id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.9 - continue-on-error: true - - name: Export sccache - if: steps.sccache-setup.outcome == 'success' - run: | - echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV - echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV - - name: "`make build`" - shell: bash - run: | - set -x - # Check that we exclude unix programs to avoid build failure - make PREFIX=/tmp/usr MULTICALL=y COMPLETIONS=n MANPAGES=n LOCALES=n \ - SKIP_UTILS="arch b2sum base32 base64 basename basenc cat cksum comm cp csplit cut date dd df dir dircolors dirname du echo env expand expr factor false fmt fold head hostname join link ln ls md5sum mkdir mktemp more mv nl nproc numfmt od paste pr printenv printf ptx pwd readlink realpath rm rmdir seq sha1sum sha224sum sha256sum sha384sum sha512sum shred shuf sleep sort split sum sync tac tail tee test touch tr truncate tsort uname unexpand uniq unlink vdir wc whoami yes" - target/debug/coreutils.exe true - test_busybox: name: Tests/BusyBox test suite runs-on: ${{ matrix.job.os }} diff --git a/Cargo.lock b/Cargo.lock index e3919220ddb..6237a36609f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -559,7 +559,6 @@ dependencies = [ "regex", "rlimit", "rstest", - "rstest_reuse", "rustc-hash", "selinux", "sha1", @@ -2559,17 +2558,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "rstest_reuse" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a8fb4672e840a587a66fc577a5491375df51ddb88f2a2c2a792598c326fe14" -dependencies = [ - "quote", - "rand 0.8.5", - "syn", -] - [[package]] name = "rust-ini" version = "0.21.3" diff --git a/Cargo.toml b/Cargo.toml index 9ef80cd072c..92013b0af57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -432,7 +432,6 @@ rayon = "1.10" regex = "1.10.4" rlimit = "0.11.0" rstest = "0.26.0" -rstest_reuse = "0.7.0" rustc-hash = "2.1.1" rust-ini = "0.21.0" same-file = "1.0.6" @@ -641,7 +640,6 @@ uucore = { workspace = true, features = [ walkdir.workspace = true hex-literal = "1.0.0" rstest.workspace = true -rstest_reuse.workspace = true [target.'cfg(unix)'.dev-dependencies] nix = { workspace = true, features = [ diff --git a/GNUmakefile b/GNUmakefile index a99fefa46ec..ae266b6c277 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -62,31 +62,44 @@ TOYBOX_ROOT := $(BASEDIR)/tmp TOYBOX_VER := 0.8.12 TOYBOX_SRC := $(TOYBOX_ROOT)/toybox-$(TOYBOX_VER) -# Detect the target system -# See https://doc.rust-lang.org/beta/rustc/platform-support.html -# todo: support building wasm -OS := $(or $(CARGO_BUILD_TARGET),$(shell rustc --print host-tuple)) +#------------------------------------------------------------------------ +# Detect the host system. +# On Windows uname -s might return MINGW_NT-* or CYGWIN_NT-*. +# Otherwise let it default to the kernel name returned by uname -s +# (Linux, Darwin, FreeBSD, …). +#------------------------------------------------------------------------ +OS ?= $(shell uname -s) # Windows does not allow symlink by default. # Allow to override LN for AppArmor. -ifneq (,$(findstring windows,$(OS))) +ifneq (,$(findstring _NT,$(OS))) LN ?= ln -f endif LN ?= ln -sf +# Possible programs +PROGS := \ + $(shell sed -n '/feat_Tier1 = \[/,/\]/p' Cargo.toml | sed '1d;2d' |tr -d '],"\n')\ + $(shell sed -n '/feat_common_core = \[/,/\]/p' Cargo.toml | sed '1d' |tr -d '],"\n') + +UNIX_PROGS := \ + $(shell sed -n '/feat_require_unix_core = \[/,/\]/p' Cargo.toml | sed '1d' |tr -d '],"\n') \ + hostid \ + pinky \ + stdbuf \ + uptime \ + users \ + who + SELINUX_PROGS := \ chcon \ runcon $(info Detected OS = $(OS)) -ifeq (,$(findstring windows,$(OS))) - FEATURE_EXTRACT_UTILS := feat_os_unix -else - FEATURE_EXTRACT_UTILS := feat_Tier1 +ifeq (,$(findstring MINGW,$(OS))) + PROGS += $(UNIX_PROGS) endif -PROGS := $(shell cargo tree --depth 1 --features $(FEATURE_EXTRACT_UTILS) --format "{p}" --prefix none | sed -E -n 's/^uu_([^ ]+).*/\1/p') - ifeq ($(SELINUX_ENABLED),1) PROGS += $(SELINUX_PROGS) endif @@ -101,7 +114,7 @@ endif # Programs with usable tests TESTS := \ - $(sort $(filter $(UTILS),$(PROGS) $(SELINUX_PROGS))) + $(sort $(filter $(UTILS),$(PROGS) $(UNIX_PROGS) $(SELINUX_PROGS))) TEST_NO_FAIL_FAST := TEST_SPEC_FEATURE := @@ -275,7 +288,7 @@ install: build install-manpages install-completions install-locales mkdir -p $(INSTALLDIR_BIN) ifneq (,$(and $(findstring stdbuf,$(UTILS)),$(findstring feat_external_libstdbuf,$(CARGOFLAGS)))) mkdir -p $(DESTDIR)$(LIBSTDBUF_DIR) -ifneq (,$(findstring cygwin,$(OS))) +ifneq (,$(findstring CYGWIN,$(OS))) $(INSTALL) -m 755 $(BUILDDIR)/deps/stdbuf.dll $(DESTDIR)$(LIBSTDBUF_DIR)/libstdbuf.dll else $(INSTALL) -m 755 $(BUILDDIR)/deps/libstdbuf.* $(DESTDIR)$(LIBSTDBUF_DIR)/ @@ -295,7 +308,7 @@ else endif uninstall: -ifeq (,$(findstring windows,$(OS))) +ifeq (,$(findstring MINGW,$(OS))) rm -f $(DESTDIR)$(LIBSTDBUF_DIR)/libstdbuf.* -rm -d $(DESTDIR)$(LIBSTDBUF_DIR) 2>/dev/null || true endif diff --git a/src/uu/b2sum/src/b2sum.rs b/src/uu/b2sum/src/b2sum.rs index ddd5fe3e9e6..6df276f2393 100644 --- a/src/uu/b2sum/src/b2sum.rs +++ b/src/uu/b2sum/src/b2sum.rs @@ -9,14 +9,18 @@ use clap::Command; use uu_checksum_common::{standalone_checksum_app_with_length, standalone_with_length_main}; -use uucore::checksum::{AlgoKind, calculate_blake_length_str}; +use uucore::checksum::{AlgoKind, calculate_blake2b_length_str}; use uucore::error::UResult; use uucore::translate; #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let calculate_blake2b_length = |s: &str| calculate_blake_length_str(AlgoKind::Blake2b, s); - standalone_with_length_main(AlgoKind::Blake2b, uu_app(), args, calculate_blake2b_length) + standalone_with_length_main( + AlgoKind::Blake2b, + uu_app(), + args, + calculate_blake2b_length_str, + ) } #[inline] diff --git a/src/uu/cksum/benches/cksum_bench.rs b/src/uu/cksum/benches/cksum_bench.rs index 81f70bbb3d1..5c2d3d9c614 100644 --- a/src/uu/cksum/benches/cksum_bench.rs +++ b/src/uu/cksum/benches/cksum_bench.rs @@ -104,7 +104,7 @@ bench_algorithm!(cksum_sha224, "sha224"); bench_algorithm!(cksum_sha256, "sha256"); bench_algorithm!(cksum_sha384, "sha384"); bench_algorithm!(cksum_sha512, "sha512"); -bench_algorithm!(cksum_blake3, "blake3"); +// broken. benchmarking error messages issues/10002 bench_algorithm!(cksum_blake3, "blake3"); bench_shake_algorithm!(cksum_shake128, "shake128", Shake128); bench_shake_algorithm!(cksum_shake256, "shake256", Shake256); diff --git a/src/uu/cksum/src/cksum.rs b/src/uu/cksum/src/cksum.rs index 19898def4f9..e484c8323ad 100644 --- a/src/uu/cksum/src/cksum.rs +++ b/src/uu/cksum/src/cksum.rs @@ -12,7 +12,7 @@ use uu_checksum_common::{ChecksumCommand, checksum_main, default_checksum_app, o use uucore::checksum::compute::OutputFormat; use uucore::checksum::{ - AlgoKind, ChecksumError, calculate_blake_length_str, sanitize_sha2_sha3_length_str, + AlgoKind, ChecksumError, calculate_blake2b_length_str, sanitize_sha2_sha3_length_str, }; use uucore::error::UResult; use uucore::hardware::{HasHardwareFeatures as _, SimdPolicy}; @@ -67,10 +67,8 @@ fn maybe_sanitize_length( Err(_) => Err(ChecksumError::InvalidLength(len.into()).into()), }, - // For BLAKE, if a length is provided, validate it. - (Some(algo @ (AlgoKind::Blake2b | AlgoKind::Blake3)), Some(len)) => { - calculate_blake_length_str(algo, len) - } + // For BLAKE2b, if a length is provided, validate it. + (Some(AlgoKind::Blake2b), Some(len)) => calculate_blake2b_length_str(len), // For any other provided algorithm, check if length is 0. // Otherwise, this is an error. diff --git a/src/uu/ls/src/config.rs b/src/uu/ls/src/config.rs deleted file mode 100644 index 12d14238f4d..00000000000 --- a/src/uu/ls/src/config.rs +++ /dev/null @@ -1,1090 +0,0 @@ -// 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. - -// spell-checker:ignore (ToDO) somegroup nlink tabsize dired subdired dtype colorterm stringly -// spell-checker:ignore nohash strtime clocale - -use std::{ - ffi::{OsStr, OsString}, - io::{IsTerminal, stdout}, - num::IntErrorKind, -}; - -use glob::Pattern; -use lscolors::LsColors; -use term_grid::SPACES_IN_TAB; - -use uucore::{ - display::Quotable, error::UResult, format::human::SizeFormat, fsext::MetadataTimeField, - line_ending::LineEnding, parser::parse_glob, parser::parse_size::parse_size_non_zero_u64, - quoting_style::QuotingStyle, show_error, show_warning, time::format, translate, -}; - -use crate::{ - LsError, - colors::{LsColorsParseError, validate_ls_colors_env}, - dired::is_dired_arg_present, - display::{Format, IndicatorStyle, LocaleQuoting, LongFormat}, - options::QUOTING_STYLE, -}; - -pub mod options { - pub mod format { - pub static ONE_LINE: &str = "1"; - pub static LONG: &str = "long"; - pub static COLUMNS: &str = "C"; - pub static ACROSS: &str = "x"; - pub static TAB_SIZE: &str = "tabsize"; - pub static COMMAS: &str = "m"; - pub static LONG_NO_OWNER: &str = "g"; - pub static LONG_NO_GROUP: &str = "o"; - pub static LONG_NUMERIC_UID_GID: &str = "numeric-uid-gid"; - } - - pub mod files { - pub static ALL: &str = "all"; - pub static ALMOST_ALL: &str = "almost-all"; - pub static UNSORTED_ALL: &str = "f"; - } - - pub mod sort { - pub static SIZE: &str = "S"; - pub static TIME: &str = "t"; - pub static NONE: &str = "U"; - pub static VERSION: &str = "v"; - pub static EXTENSION: &str = "X"; - } - - pub mod time { - pub static ACCESS: &str = "u"; - pub static CHANGE: &str = "c"; - } - - pub mod size { - pub static ALLOCATION_SIZE: &str = "size"; - pub static BLOCK_SIZE: &str = "block-size"; - pub static HUMAN_READABLE: &str = "human-readable"; - pub static SI: &str = "si"; - pub static KIBIBYTES: &str = "kibibytes"; - } - - pub mod quoting { - pub static ESCAPE: &str = "escape"; - pub static LITERAL: &str = "literal"; - pub static C: &str = "quote-name"; - } - - pub mod indicator_style { - pub static SLASH: &str = "p"; - pub static FILE_TYPE: &str = "file-type"; - pub static CLASSIFY: &str = "classify"; - } - - pub mod dereference { - pub static ALL: &str = "dereference"; - pub static ARGS: &str = "dereference-command-line"; - pub static DIR_ARGS: &str = "dereference-command-line-symlink-to-dir"; - } - - pub static HELP: &str = "help"; - pub static QUOTING_STYLE: &str = "quoting-style"; - pub static HIDE_CONTROL_CHARS: &str = "hide-control-chars"; - pub static SHOW_CONTROL_CHARS: &str = "show-control-chars"; - pub static WIDTH: &str = "width"; - pub static AUTHOR: &str = "author"; - pub static NO_GROUP: &str = "no-group"; - pub static FORMAT: &str = "format"; - pub static SORT: &str = "sort"; - pub static TIME: &str = "time"; - pub static IGNORE_BACKUPS: &str = "ignore-backups"; - pub static DIRECTORY: &str = "directory"; - pub static INODE: &str = "inode"; - pub static REVERSE: &str = "reverse"; - pub static RECURSIVE: &str = "recursive"; - pub static COLOR: &str = "color"; - pub static PATHS: &str = "paths"; - pub static INDICATOR_STYLE: &str = "indicator-style"; - pub static TIME_STYLE: &str = "time-style"; - pub static FULL_TIME: &str = "full-time"; - pub static HIDE: &str = "hide"; - pub static IGNORE: &str = "ignore"; - pub static CONTEXT: &str = "context"; - pub static GROUP_DIRECTORIES_FIRST: &str = "group-directories-first"; - pub static ZERO: &str = "zero"; - pub static DIRED: &str = "dired"; - pub static HYPERLINK: &str = "hyperlink"; -} - -const DEFAULT_TERM_WIDTH: u16 = 80; -const POSIXLY_CORRECT_BLOCK_SIZE: u64 = 512; -const DEFAULT_BLOCK_SIZE: u64 = 1024; -const DEFAULT_FILE_SIZE_BLOCK_SIZE: u64 = 1; - -pub(crate) enum Dereference { - None, - DirArgs, - Args, - All, -} - -#[derive(PartialEq, Eq)] -pub(crate) enum Sort { - None, - Name, - Size, - Time, - Version, - Extension, - Width, -} - -#[derive(PartialEq, Eq)] -pub(crate) enum Files { - All, - AlmostAll, - Normal, -} - -pub struct Config { - // Dir and vdir needs access to this field - pub format: Format, - pub(crate) files: Files, - pub(crate) sort: Sort, - pub(crate) recursive: bool, - pub(crate) reverse: bool, - pub(crate) dereference: Dereference, - pub(crate) ignore_patterns: Vec, - pub(crate) size_format: SizeFormat, - pub(crate) directory: bool, - pub(crate) time: MetadataTimeField, - #[cfg(unix)] - pub(crate) inode: bool, - pub(crate) color: Option, - pub(crate) long: LongFormat, - pub(crate) alloc_size: bool, - pub(crate) file_size_block_size: u64, - #[allow(dead_code)] - pub(crate) block_size: u64, // is never read on Windows - pub(crate) width: u16, - // Dir and vdir needs access to this field - pub quoting_style: QuotingStyle, - pub(crate) locale_quoting: Option, - pub(crate) indicator_style: IndicatorStyle, - pub(crate) time_format_recent: String, // Time format for recent dates - pub(crate) time_format_older: Option, // Time format for older dates (optional, if not present, time_format_recent is used) - pub(crate) context: bool, - #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] - pub(crate) selinux_supported: bool, - #[cfg(all(feature = "smack", target_os = "linux"))] - pub(crate) smack_supported: bool, - pub(crate) group_directories_first: bool, - pub(crate) line_ending: LineEnding, - pub(crate) dired: bool, - pub(crate) hyperlink: bool, - pub(crate) tab_size: usize, -} - -/// Extracts the format to display the information based on the options provided. -/// -/// # Returns -/// -/// A tuple containing the Format variant and an Option containing a &'static str -/// which corresponds to the option used to define the format. -fn extract_format(options: &clap::ArgMatches) -> (Format, Option<&'static str>) { - if let Some(format_) = options.get_one::(options::FORMAT) { - ( - match format_.as_str() { - "long" | "verbose" => Format::Long, - "single-column" => Format::OneLine, - "columns" | "vertical" => Format::Columns, - "across" | "horizontal" => Format::Across, - "commas" => Format::Commas, - // below should never happen as clap already restricts the values. - _ => unreachable!("Invalid field for --format"), - }, - Some(options::FORMAT), - ) - } else if options.get_flag(options::format::LONG) { - (Format::Long, Some(options::format::LONG)) - } else if options.get_flag(options::format::ACROSS) { - (Format::Across, Some(options::format::ACROSS)) - } else if options.get_flag(options::format::COMMAS) { - (Format::Commas, Some(options::format::COMMAS)) - } else if options.get_flag(options::format::COLUMNS) { - (Format::Columns, Some(options::format::COLUMNS)) - } else if stdout().is_terminal() { - (Format::Columns, None) - } else { - (Format::OneLine, None) - } -} - -/// Extracts the type of files to display -/// -/// # Returns -/// -/// A Files variant representing the type of files to display. -fn extract_files(options: &clap::ArgMatches) -> Files { - let get_last_index = |flag: &str| -> usize { - if options.value_source(flag) == Some(clap::parser::ValueSource::CommandLine) { - options.index_of(flag).unwrap_or(0) - } else { - 0 - } - }; - - let all_index = get_last_index(options::files::ALL); - let almost_all_index = get_last_index(options::files::ALMOST_ALL); - let unsorted_all_index = get_last_index(options::files::UNSORTED_ALL); - - let max_index = all_index.max(almost_all_index).max(unsorted_all_index); - - if max_index == 0 { - Files::Normal - } else if max_index == almost_all_index { - Files::AlmostAll - } else { - // Either -a or -f wins, both show all files - Files::All - } -} - -/// Extracts the sorting method to use based on the options provided. -/// -/// # Returns -/// -/// A Sort variant representing the sorting method to use. -fn extract_sort(options: &clap::ArgMatches) -> Sort { - let get_last_index = |flag: &str| -> usize { - if options.value_source(flag) == Some(clap::parser::ValueSource::CommandLine) { - options.index_of(flag).unwrap_or(0) - } else { - 0 - } - }; - - let sort_index = options - .get_one::(options::SORT) - .and_then(|_| options.indices_of(options::SORT)) - .map_or(0, |mut indices| indices.next_back().unwrap_or(0)); - let time_index = get_last_index(options::sort::TIME); - let size_index = get_last_index(options::sort::SIZE); - let none_index = get_last_index(options::sort::NONE); - let version_index = get_last_index(options::sort::VERSION); - let extension_index = get_last_index(options::sort::EXTENSION); - let unsorted_all_index = get_last_index(options::files::UNSORTED_ALL); - - let max_sort_index = sort_index - .max(time_index) - .max(size_index) - .max(none_index) - .max(version_index) - .max(extension_index) - .max(unsorted_all_index); - - match max_sort_index { - 0 => { - // No sort flags specified, use default behavior - if !options.get_flag(options::format::LONG) - && (options.get_flag(options::time::ACCESS) - || options.get_flag(options::time::CHANGE) - || options.get_one::(options::TIME).is_some()) - { - Sort::Time - } else { - Sort::Name - } - } - idx if idx == unsorted_all_index || idx == none_index => Sort::None, - idx if idx == sort_index => { - if let Some(field) = options.get_one::(options::SORT) { - match field.as_str() { - "none" => Sort::None, - "name" => Sort::Name, - "time" => Sort::Time, - "size" => Sort::Size, - "version" => Sort::Version, - "extension" => Sort::Extension, - "width" => Sort::Width, - _ => unreachable!("Invalid field for --sort"), - } - } else { - Sort::Name - } - } - idx if idx == time_index => Sort::Time, - idx if idx == size_index => Sort::Size, - idx if idx == version_index => Sort::Version, - idx if idx == extension_index => Sort::Extension, - _ => Sort::Name, - } -} - -/// Extracts the time to use based on the options provided. -/// -/// # Returns -/// -/// A `MetadataTimeField` variant representing the time to use. -fn extract_time(options: &clap::ArgMatches) -> MetadataTimeField { - if let Some(field) = options.get_one::(options::TIME) { - field.as_str().into() - } else if options.get_flag(options::time::ACCESS) { - MetadataTimeField::Access - } else if options.get_flag(options::time::CHANGE) { - MetadataTimeField::Change - } else { - MetadataTimeField::Modification - } -} - -/// Some env variables can be passed -/// For now, we are only verifying if empty or not and known for `TERM` -fn is_color_compatible_term() -> bool { - let is_term_set = std::env::var("TERM").is_ok(); - let is_colorterm_set = std::env::var("COLORTERM").is_ok(); - - let term = std::env::var("TERM").unwrap_or_default(); - let colorterm = std::env::var("COLORTERM").unwrap_or_default(); - - // Search function in the TERM struct to manage the wildcards - let term_matches = |term: &str| -> bool { - uucore::colors::TERMS.iter().any(|&pattern| { - term == pattern - || (pattern.ends_with('*') && term.starts_with(&pattern[..pattern.len() - 1])) - }) - }; - - if is_term_set && term.is_empty() && is_colorterm_set && colorterm.is_empty() { - return false; - } - - if !term.is_empty() && !term_matches(&term) { - return false; - } - true -} - -/// Extracts the color option to use based on the options provided. -/// -/// # Returns -/// -/// A boolean representing whether or not to use color. -fn extract_color(options: &clap::ArgMatches) -> bool { - if !is_color_compatible_term() { - return false; - } - - let get_last_index = |flag: &str| -> usize { - if options.value_source(flag) == Some(clap::parser::ValueSource::CommandLine) { - options.index_of(flag).unwrap_or(0) - } else { - 0 - } - }; - - let color_index = options - .get_one::(options::COLOR) - .and_then(|_| options.indices_of(options::COLOR)) - .map_or(0, |mut indices| indices.next_back().unwrap_or(0)); - let unsorted_all_index = get_last_index(options::files::UNSORTED_ALL); - - let color_enabled = match options.get_one::(options::COLOR) { - None => options.contains_id(options::COLOR), - Some(val) => match val.as_str() { - "" | "always" | "yes" | "force" => true, - "auto" | "tty" | "if-tty" => stdout().is_terminal(), - /* "never" | "no" | "none" | */ _ => false, - }, - }; - - // If --color was explicitly specified, always honor it regardless of -f - // Otherwise, if -f is present without explicit color, disable color - if color_index > 0 { - // Color was explicitly specified - color_enabled - } else if unsorted_all_index > 0 { - // -f present without explicit color, disable implicit color - false - } else { - color_enabled - } -} - -/// Extracts the hyperlink option to use based on the options provided. -/// -/// # Returns -/// -/// A boolean representing whether to hyperlink files. -fn extract_hyperlink(options: &clap::ArgMatches) -> bool { - let hyperlink = options - .get_one::(options::HYPERLINK) - .unwrap() - .as_str(); - - match hyperlink { - "always" | "yes" | "force" => true, - "auto" | "tty" | "if-tty" => stdout().is_terminal(), - "never" | "no" | "none" => false, - _ => unreachable!("should be handled by clap"), - } -} - -/// Match the argument given to --quoting-style or the [`QUOTING_STYLE`] env variable. -/// -/// # Arguments -/// -/// * `style`: the actual argument string -/// * `show_control` - A boolean value representing whether to show control characters. -/// -/// # Returns -/// -/// * An option with None if the style string is invalid, or a `QuotingStyle` wrapped in `Some`. -struct QuotingStyleSpec { - style: QuotingStyle, - fixed_control: bool, - locale: Option, -} - -impl QuotingStyleSpec { - fn new(style: QuotingStyle) -> Self { - Self { - style, - fixed_control: false, - locale: None, - } - } - - fn with_locale(style: QuotingStyle, locale: LocaleQuoting) -> Self { - Self { - style, - fixed_control: true, - locale: Some(locale), - } - } -} -fn match_quoting_style_name( - style: &str, - show_control: bool, -) -> Option<(QuotingStyle, Option)> { - let spec = match style { - "literal" => QuotingStyleSpec::new(QuotingStyle::Literal { - show_control: false, - }), - "shell" => QuotingStyleSpec::new(QuotingStyle::SHELL), - "shell-always" => QuotingStyleSpec::new(QuotingStyle::SHELL_QUOTE), - "shell-escape" => QuotingStyleSpec::new(QuotingStyle::SHELL_ESCAPE), - "shell-escape-always" => QuotingStyleSpec::new(QuotingStyle::SHELL_ESCAPE_QUOTE), - "c" => QuotingStyleSpec::new(QuotingStyle::C_DOUBLE), - "escape" => QuotingStyleSpec::new(QuotingStyle::C_NO_QUOTES), - "locale" => QuotingStyleSpec { - style: QuotingStyle::Literal { - show_control: false, - }, - fixed_control: true, - locale: Some(LocaleQuoting::Single), - }, - "clocale" => QuotingStyleSpec::with_locale(QuotingStyle::C_DOUBLE, LocaleQuoting::Double), - _ => return None, - }; - - let style = if spec.fixed_control { - spec.style - } else { - spec.style.show_control(show_control) - }; - - Some((style, spec.locale)) -} - -/// Extracts the quoting style to use based on the options provided. -/// If no options are given, it looks if a default quoting style is provided -/// through the [`QUOTING_STYLE`] environment variable. -/// -/// # Arguments -/// -/// * `options` - A reference to a [`clap::ArgMatches`] object containing command line arguments. -/// * `show_control` - A boolean value representing whether or not to show control characters. -/// -/// # Returns -/// -/// A [`QuotingStyle`] variant representing the quoting style to use. -fn extract_quoting_style( - options: &clap::ArgMatches, - show_control: bool, -) -> (QuotingStyle, Option) { - let opt_quoting_style = options.get_one::(QUOTING_STYLE); - - if let Some(style) = opt_quoting_style { - match match_quoting_style_name(style, show_control) { - Some(pair) => pair, - None => unreachable!("Should have been caught by Clap"), - } - } else if options.get_flag(options::quoting::LITERAL) { - (QuotingStyle::Literal { show_control }, None) - } else if options.get_flag(options::quoting::ESCAPE) { - (QuotingStyle::C_NO_QUOTES, None) - } else if options.get_flag(options::quoting::C) { - (QuotingStyle::C_DOUBLE, None) - } else if options.get_flag(options::DIRED) { - (QuotingStyle::Literal { show_control }, None) - } else { - // If set, the QUOTING_STYLE environment variable specifies a default style. - if let Ok(style) = std::env::var("QUOTING_STYLE") { - match match_quoting_style_name(style.as_str(), show_control) { - Some(pair) => return pair, - None => eprintln!( - "{}", - translate!("ls-invalid-quoting-style", "program" => std::env::args().next().unwrap_or_else(|| "ls".to_string()), "style" => style.clone()) - ), - } - } - - // By default, `ls` uses Shell escape quoting style when writing to a terminal file - // descriptor and Literal otherwise. - if stdout().is_terminal() { - (QuotingStyle::SHELL_ESCAPE.show_control(show_control), None) - } else { - (QuotingStyle::Literal { show_control }, None) - } - } -} - -/// Extracts the indicator style to use based on the options provided. -/// -/// # Returns -/// -/// An [`IndicatorStyle`] variant representing the indicator style to use. -fn extract_indicator_style(options: &clap::ArgMatches) -> IndicatorStyle { - if let Some(field) = options.get_one::(options::INDICATOR_STYLE) { - match field.as_str() { - "none" => IndicatorStyle::None, - "file-type" => IndicatorStyle::FileType, - "classify" => IndicatorStyle::Classify, - "slash" => IndicatorStyle::Slash, - &_ => IndicatorStyle::None, - } - } else if let Some(field) = options.get_one::(options::indicator_style::CLASSIFY) { - match field.as_str() { - "never" | "no" | "none" => IndicatorStyle::None, - "always" | "yes" | "force" => IndicatorStyle::Classify, - "auto" | "tty" | "if-tty" => { - if stdout().is_terminal() { - IndicatorStyle::Classify - } else { - IndicatorStyle::None - } - } - &_ => IndicatorStyle::None, - } - } else if options.get_flag(options::indicator_style::SLASH) { - IndicatorStyle::Slash - } else if options.get_flag(options::indicator_style::FILE_TYPE) { - IndicatorStyle::FileType - } else { - IndicatorStyle::None - } -} - -/// Parses the width value from either the command line arguments or the environment variables. -fn parse_width(width_match: Option<&String>) -> Result { - let parse_width_from_args = |s: &str| -> Result { - let radix = if s.starts_with('0') && s.len() > 1 { - 8 - } else { - 10 - }; - match u16::from_str_radix(s, radix) { - Ok(x) => Ok(x), - Err(e) => match e.kind() { - IntErrorKind::PosOverflow => Ok(u16::MAX), - _ => Err(LsError::InvalidLineWidth(s.into())), - }, - } - }; - - let parse_width_from_env = |columns: OsString| { - if let Some(columns) = columns.to_str().and_then(|s| s.parse().ok()) { - columns - } else { - show_error!( - "{}", - translate!("ls-invalid-columns-width", "width" => columns.quote()) - ); - DEFAULT_TERM_WIDTH - } - }; - - let calculate_term_size = || match terminal_size::terminal_size() { - Some((width, _)) => width.0, - None => DEFAULT_TERM_WIDTH, - }; - - let ret = match width_match { - Some(x) => parse_width_from_args(x)?, - None => match std::env::var_os("COLUMNS") { - Some(columns) => parse_width_from_env(columns), - None => calculate_term_size(), - }, - }; - - Ok(ret) -} - -impl Config { - #[allow(clippy::cognitive_complexity)] - pub fn from(options: &clap::ArgMatches) -> UResult { - let context = options.get_flag(options::CONTEXT); - let (mut format, opt) = extract_format(options); - let files = extract_files(options); - - // The -o, -n and -g options are tricky. They cannot override with each - // other because it's possible to combine them. For example, the option - // -og should hide both owner and group. Furthermore, they are not - // reset if -l or --format=long is used. So these should just show the - // group: -gl or "-g --format=long". Finally, they are also not reset - // when switching to a different format option in-between like this: - // -ogCl or "-og --format=vertical --format=long". - // - // -1 has a similar issue: it does nothing if the format is long. This - // actually makes it distinct from the --format=singe-column option, - // which always applies. - // - // The idea here is to not let these options override with the other - // options, but manually whether they have an index that's greater than - // the other format options. If so, we set the appropriate format. - if format != Format::Long { - let idx = opt - .and_then(|opt| options.indices_of(opt).map(|x| x.max().unwrap())) - .unwrap_or(0); - if [ - options::format::LONG_NO_OWNER, - options::format::LONG_NO_GROUP, - options::format::LONG_NUMERIC_UID_GID, - options::FULL_TIME, - ] - .iter() - .filter_map(|opt| { - if options.value_source(opt) == Some(clap::parser::ValueSource::CommandLine) { - options.indices_of(opt) - } else { - None - } - }) - .flatten() - .any(|i| i >= idx) - { - format = Format::Long; - } else if let Some(mut indices) = options.indices_of(options::format::ONE_LINE) { - if options.value_source(options::format::ONE_LINE) - == Some(clap::parser::ValueSource::CommandLine) - && indices.any(|i| i > idx) - { - format = Format::OneLine; - } - } - } - - let sort = extract_sort(options); - let time = extract_time(options); - let mut needs_color = extract_color(options); - let hyperlink = extract_hyperlink(options); - - let opt_block_size = options.get_one::(options::size::BLOCK_SIZE); - let opt_si = opt_block_size.is_some() - && options - .get_one::(options::size::BLOCK_SIZE) - .unwrap() - .eq("si") - || options.get_flag(options::size::SI); - let opt_hr = (opt_block_size.is_some() - && options - .get_one::(options::size::BLOCK_SIZE) - .unwrap() - .eq("human-readable")) - || options.get_flag(options::size::HUMAN_READABLE); - let opt_kb = options.get_flag(options::size::KIBIBYTES); - - let size_format = if opt_si { - SizeFormat::Decimal - } else if opt_hr { - SizeFormat::Binary - } else { - SizeFormat::Bytes - }; - - let env_var_blocksize = std::env::var_os("BLOCKSIZE"); - let env_var_block_size = std::env::var_os("BLOCK_SIZE"); - let env_var_ls_block_size = std::env::var_os("LS_BLOCK_SIZE"); - let env_var_posixly_correct = std::env::var_os("POSIXLY_CORRECT"); - let mut is_env_var_blocksize = false; - - let raw_block_size = if let Some(opt_block_size) = opt_block_size { - OsString::from(opt_block_size) - } else if let Some(env_var_ls_block_size) = env_var_ls_block_size { - env_var_ls_block_size - } else if let Some(env_var_block_size) = env_var_block_size { - env_var_block_size - } else if let Some(env_var_blocksize) = env_var_blocksize { - is_env_var_blocksize = true; - env_var_blocksize - } else { - OsString::from("") - }; - - let (file_size_block_size, block_size) = if !opt_si && !opt_hr && !raw_block_size.is_empty() - { - if let Ok(size) = parse_size_non_zero_u64(&raw_block_size.to_string_lossy()) { - match (is_env_var_blocksize, opt_kb) { - (true, true) => (DEFAULT_FILE_SIZE_BLOCK_SIZE, DEFAULT_BLOCK_SIZE), - (true, false) => (DEFAULT_FILE_SIZE_BLOCK_SIZE, size), - (false, true) => { - // --block-size overrides -k - if opt_block_size.is_some() { - (size, size) - } else { - (size, DEFAULT_BLOCK_SIZE) - } - } - (false, false) => (size, size), - } - } else { - // only fail if invalid block size was specified with --block-size, - // ignore invalid block size from env vars - if let Some(invalid_block_size) = opt_block_size { - return Err(Box::new(LsError::BlockSizeParseError( - invalid_block_size.clone(), - ))); - } - if is_env_var_blocksize { - (DEFAULT_FILE_SIZE_BLOCK_SIZE, DEFAULT_BLOCK_SIZE) - } else { - (DEFAULT_BLOCK_SIZE, DEFAULT_BLOCK_SIZE) - } - } - } else if env_var_posixly_correct.is_some() { - if opt_kb { - (DEFAULT_FILE_SIZE_BLOCK_SIZE, DEFAULT_BLOCK_SIZE) - } else { - (DEFAULT_FILE_SIZE_BLOCK_SIZE, POSIXLY_CORRECT_BLOCK_SIZE) - } - } else if opt_si { - (DEFAULT_FILE_SIZE_BLOCK_SIZE, 1000) - } else { - (DEFAULT_FILE_SIZE_BLOCK_SIZE, DEFAULT_BLOCK_SIZE) - }; - - let long = { - let author = options.get_flag(options::AUTHOR); - let group = !options.get_flag(options::NO_GROUP) - && !options.get_flag(options::format::LONG_NO_GROUP); - let owner = !options.get_flag(options::format::LONG_NO_OWNER); - #[cfg(unix)] - let numeric_uid_gid = options.get_flag(options::format::LONG_NUMERIC_UID_GID); - LongFormat { - author, - group, - owner, - #[cfg(unix)] - numeric_uid_gid, - } - }; - let width = parse_width(options.get_one::(options::WIDTH))?; - - #[allow(clippy::needless_bool)] - let mut show_control = if options.get_flag(options::HIDE_CONTROL_CHARS) { - false - } else if options.get_flag(options::SHOW_CONTROL_CHARS) { - true - } else { - !stdout().is_terminal() - }; - - let (mut quoting_style, mut locale_quoting) = extract_quoting_style(options, show_control); - let indicator_style = extract_indicator_style(options); - // Only parse the value to "--time-style" if it will become relevant. - let dired = options.get_flag(options::DIRED); - let (time_format_recent, time_format_older) = if format == Format::Long || dired { - parse_time_style(options)? - } else { - Default::default() - }; - - let mut ignore_patterns: Vec = Vec::new(); - - if options.get_flag(options::IGNORE_BACKUPS) { - ignore_patterns.push(Pattern::new("*~").unwrap()); - ignore_patterns.push(Pattern::new(".*~").unwrap()); - } - - for pattern in options - .get_many::(options::IGNORE) - .into_iter() - .flatten() - { - if let Ok(p) = parse_glob::from_str(pattern) { - ignore_patterns.push(p); - } else { - show_warning!( - "{}", - translate!("ls-invalid-ignore-pattern", "pattern" => pattern.quote()) - ); - } - } - - if files == Files::Normal { - for pattern in options - .get_many::(options::HIDE) - .into_iter() - .flatten() - { - if let Ok(p) = parse_glob::from_str(pattern) { - ignore_patterns.push(p); - } else { - show_warning!( - "{}", - translate!("ls-invalid-hide-pattern", "pattern" => pattern.quote()) - ); - } - } - } - - // According to ls info page, `--zero` implies the following flags: - // - `--show-control-chars` - // - `--format=single-column` - // - `--color=none` - // - `--quoting-style=literal` - // Current GNU ls implementation allows `--zero` Behavior to be - // overridden by later flags. - let zero_formats_opts = [ - options::format::ACROSS, - options::format::COLUMNS, - options::format::COMMAS, - options::format::LONG, - options::format::LONG_NO_GROUP, - options::format::LONG_NO_OWNER, - options::format::LONG_NUMERIC_UID_GID, - options::format::ONE_LINE, - options::FORMAT, - ]; - let zero_colors_opts = [options::COLOR]; - let zero_show_control_opts = [options::HIDE_CONTROL_CHARS, options::SHOW_CONTROL_CHARS]; - let zero_quoting_style_opts = [ - QUOTING_STYLE, - options::quoting::C, - options::quoting::ESCAPE, - options::quoting::LITERAL, - ]; - let get_last = |flag: &str| -> usize { - if options.value_source(flag) == Some(clap::parser::ValueSource::CommandLine) { - options.index_of(flag).unwrap_or(0) - } else { - 0 - } - }; - if get_last(options::ZERO) - > zero_formats_opts - .into_iter() - .map(get_last) - .max() - .unwrap_or(0) - { - format = if format == Format::Long { - format - } else { - Format::OneLine - }; - } - if get_last(options::ZERO) - > zero_colors_opts - .into_iter() - .map(get_last) - .max() - .unwrap_or(0) - { - needs_color = false; - } - if get_last(options::ZERO) - > zero_show_control_opts - .into_iter() - .map(get_last) - .max() - .unwrap_or(0) - { - show_control = true; - } - if get_last(options::ZERO) - > zero_quoting_style_opts - .into_iter() - .map(get_last) - .max() - .unwrap_or(0) - { - quoting_style = QuotingStyle::Literal { show_control }; - locale_quoting = None; - } - - if needs_color { - if let Err(err) = validate_ls_colors_env() { - if let LsColorsParseError::UnrecognizedPrefix(prefix) = &err { - show_warning!( - "{}", - translate!( - "ls-warning-unrecognized-ls-colors-prefix", - "prefix" => prefix.quote() - ) - ); - } - show_warning!("{}", translate!("ls-warning-unparsable-ls-colors")); - needs_color = false; - } - } - - let color = if needs_color { - Some(LsColors::from_env().unwrap_or_default()) - } else { - None - }; - - if dired || is_dired_arg_present() { - // --dired implies --format=long - // if we have --dired --hyperlink, we don't show dired but we still want to see the - // long format - format = Format::Long; - } - if dired && options.get_flag(options::ZERO) { - return Err(Box::new(LsError::DiredAndZeroAreIncompatible)); - } - - let dereference = if options.get_flag(options::dereference::ALL) { - Dereference::All - } else if options.get_flag(options::dereference::ARGS) { - Dereference::Args - } else if options.get_flag(options::dereference::DIR_ARGS) { - Dereference::DirArgs - } else if options.get_flag(options::DIRECTORY) - || indicator_style == IndicatorStyle::Classify - || format == Format::Long - { - Dereference::None - } else { - Dereference::DirArgs - }; - - let tab_size = if needs_color { - Some(0) - } else { - options - .get_one::(options::format::TAB_SIZE) - .and_then(|size| size.parse::().ok()) - .or_else(|| std::env::var("TABSIZE").ok().and_then(|s| s.parse().ok())) - } - .unwrap_or(SPACES_IN_TAB); - - Ok(Self { - format, - files, - sort, - recursive: options.get_flag(options::RECURSIVE), - reverse: options.get_flag(options::REVERSE), - dereference, - ignore_patterns, - size_format, - directory: options.get_flag(options::DIRECTORY), - time, - color, - #[cfg(unix)] - inode: options.get_flag(options::INODE), - long, - alloc_size: options.get_flag(options::size::ALLOCATION_SIZE), - file_size_block_size, - block_size, - width, - quoting_style, - locale_quoting, - indicator_style, - time_format_recent, - time_format_older, - context, - #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] - selinux_supported: uucore::selinux::is_selinux_enabled(), - #[cfg(all(feature = "smack", target_os = "linux"))] - smack_supported: uucore::smack::is_smack_enabled(), - group_directories_first: options.get_flag(options::GROUP_DIRECTORIES_FIRST), - line_ending: LineEnding::from_zero_flag(options.get_flag(options::ZERO)), - dired, - hyperlink, - tab_size, - }) - } -} - -fn parse_time_style(options: &clap::ArgMatches) -> Result<(String, Option), LsError> { - // TODO: Using correct locale string is not implemented. - const LOCALE_FORMAT: (&str, Option<&str>) = ("%b %e %H:%M", Some("%b %e %Y")); - - // Convert time_styles references to owned String/option. - #[expect(clippy::unnecessary_wraps, reason = "internal result helper")] - fn ok((recent, older): (&str, Option<&str>)) -> Result<(String, Option), LsError> { - Ok((recent.to_string(), older.map(String::from))) - } - - if let Some(field) = options - .get_one::(options::TIME_STYLE) - .map(ToOwned::to_owned) - .or_else(|| std::env::var("TIME_STYLE").ok()) - { - //If both FULL_TIME and TIME_STYLE are present - //The one added last is dominant - if options.get_flag(options::FULL_TIME) - && options.indices_of(options::FULL_TIME).unwrap().next_back() - > options.indices_of(options::TIME_STYLE).unwrap().next_back() - { - ok((format::FULL_ISO, None)) - } else { - let field = if let Some(field) = field.strip_prefix("posix-") { - // See GNU documentation, set format to "locale" if LC_TIME="POSIX", - // else just strip the prefix and continue (even "posix+FORMAT" is - // supported). - // TODO: This needs to be moved to uucore and handled by icu? - if std::env::var_os("LC_TIME").as_deref() == Some(OsStr::new("POSIX")) - || std::env::var_os("LC_ALL").as_deref() == Some(OsStr::new("POSIX")) - { - return ok(LOCALE_FORMAT); - } - field - } else { - &field - }; - - match field { - "full-iso" => ok((format::FULL_ISO, None)), - "long-iso" => ok((format::LONG_ISO, None)), - // ISO older format needs extra padding. - "iso" => Ok(( - "%m-%d %H:%M".to_string(), - Some(format::ISO.to_string() + " "), - )), - "locale" => ok(LOCALE_FORMAT), - _ => match field.chars().next().unwrap() { - '+' => { - // recent/older formats are (optionally) separated by a newline - let mut it = field[1..].split('\n'); - let recent = it.next().unwrap_or_default(); - let older = it.next(); - match it.next() { - None => ok((recent, older)), - Some(_) => Err(LsError::TimeStyleParseError(String::from(field))), - } - } - _ => Err(LsError::TimeStyleParseError(String::from(field))), - }, - } - } - } else if options.get_flag(options::FULL_TIME) { - ok((format::FULL_ISO, None)) - } else { - ok(LOCALE_FORMAT) - } -} diff --git a/src/uu/ls/src/display.rs b/src/uu/ls/src/display.rs deleted file mode 100644 index 4f77ebd8720..00000000000 --- a/src/uu/ls/src/display.rs +++ /dev/null @@ -1,1319 +0,0 @@ -// 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. - -// spell-checker:ignore (ToDO) somegroup nlink tabsize dired subdired dtype colorterm stringly -// spell-checker:ignore nohash strtime clocale - -use std::cell::LazyCell; -#[cfg(unix)] -use std::os::unix::fs::{FileTypeExt, MetadataExt}; -#[cfg(windows)] -use std::os::windows::fs::MetadataExt; -/// Show the directory name in the case where several arguments are given to ls -use std::{borrow::Cow, iter}; -use std::{ - ffi::{OsStr, OsString}, - fmt::Write as _, - fs::{self, DirEntry, FileType, Metadata}, - io::{BufWriter, Stdout, Write}, -}; - -use ansi_width::ansi_width; -use glob::MatchOptions; -use term_grid::{DEFAULT_SEPARATOR_SIZE, Direction, Filling, Grid, GridOptions}; - -#[cfg(unix)] -use uucore::entries; -#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] -use uucore::fsxattr::has_acl; -#[cfg(any( - target_os = "linux", - target_os = "macos", - target_os = "android", - target_os = "ios", - target_os = "freebsd", - target_os = "dragonfly", - target_os = "netbsd", - target_os = "openbsd", - target_os = "illumos", - target_os = "solaris" -))] -use uucore::libc::{dev_t, major, minor}; -use uucore::{ - error::UResult, - format::human::human_readable, - fs::display_permissions, - fsext::metadata_get_time, - line_ending::LineEnding, - os_str_as_bytes_lossy, - quoting_style::{QuotingStyle, locale_aware_escape_dir_name, locale_aware_escape_name}, - show, - time::{FormatSystemTimeFallback, format_system_time}, -}; - -use crate::colors::color_name; -use crate::config::Files; -use crate::dired::{self, DiredOutput}; -use crate::{Config, ListState, LsError, PathData, get_block_size}; - -// Fields that can be removed or added to the long format -pub(crate) struct LongFormat { - pub(crate) author: bool, - pub(crate) group: bool, - pub(crate) owner: bool, - #[cfg(unix)] - pub(crate) numeric_uid_gid: bool, -} - -pub(crate) struct PaddingCollection { - #[cfg(unix)] - pub(crate) inode: usize, - pub(crate) link_count: usize, - pub(crate) uname: usize, - pub(crate) group: usize, - pub(crate) context: usize, - pub(crate) size: usize, - #[cfg(unix)] - pub(crate) major: usize, - #[cfg(unix)] - pub(crate) minor: usize, - pub(crate) block_size: usize, -} - -pub(crate) struct DisplayItemName { - pub(crate) displayed: OsString, - pub(crate) dired_name_len: usize, -} - -#[derive(PartialEq, Eq)] -pub(crate) enum IndicatorStyle { - None, - Slash, - FileType, - Classify, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -pub(crate) enum LocaleQuoting { - Single, - Double, -} - -#[derive(PartialEq, Eq, Debug)] -pub enum Format { - Columns, - Long, - OneLine, - Across, - Commas, -} - -#[allow(dead_code)] -enum SizeOrDeviceId { - Size(String), - Device(String, String), -} - -/// or the recursive flag is passed. -/// -/// ```no-exec -/// $ ls -R -/// .: <- This is printed by this function -/// dir1 file1 file2 -/// -/// dir1: <- This as well -/// file11 -/// ``` -pub fn show_dir_name( - path_data: &PathData, - out: &mut BufWriter, - config: &Config, -) -> std::io::Result<()> { - let escaped_name = escape_dir_name_with_locale(path_data.path().as_os_str(), config); - - let name = if config.hyperlink && !config.dired { - create_hyperlink(&escaped_name, path_data) - } else { - escaped_name - }; - - write_os_str(out, &name)?; - write!(out, ":") -} - -fn escape_with_locale(name: &OsStr, config: &Config, fallback: F) -> OsString -where - F: FnOnce(&OsStr, QuotingStyle) -> OsString, -{ - if let Some(locale) = config.locale_quoting { - locale_quote(name, locale) - } else { - fallback(name, config.quoting_style) - } -} - -fn escape_dir_name_with_locale(name: &OsStr, config: &Config) -> OsString { - escape_with_locale(name, config, locale_aware_escape_dir_name) -} - -fn escape_name_with_locale(name: &OsStr, config: &Config) -> OsString { - escape_with_locale(name, config, locale_aware_escape_name) -} - -fn locale_quote(name: &OsStr, style: LocaleQuoting) -> OsString { - let bytes = os_str_as_bytes_lossy(name); - let mut quoted = String::new(); - match style { - LocaleQuoting::Single => quoted.push('\''), - LocaleQuoting::Double => quoted.push('"'), - } - for &byte in bytes.as_ref() { - push_locale_byte(&mut quoted, byte, style); - } - match style { - LocaleQuoting::Single => quoted.push('\''), - LocaleQuoting::Double => quoted.push('"'), - } - OsString::from(quoted) -} - -fn push_locale_byte(buf: &mut String, byte: u8, style: LocaleQuoting) { - match (style, byte) { - (LocaleQuoting::Single, b'\'') => buf.push_str("'\\''"), - (LocaleQuoting::Double, b'"') => buf.push_str("\\\""), - (_, b'\\') => buf.push_str("\\\\"), - _ => push_basic_escape(buf, byte), - } -} - -fn push_basic_escape(buf: &mut String, byte: u8) { - match byte { - b'\x07' => buf.push_str("\\a"), - b'\x08' => buf.push_str("\\b"), - b'\t' => buf.push_str("\\t"), - b'\n' => buf.push_str("\\n"), - b'\x0b' => buf.push_str("\\v"), - b'\x0c' => buf.push_str("\\f"), - b'\r' => buf.push_str("\\r"), - b'\x1b' => buf.push_str("\\e"), - b'"' => buf.push('"'), - b'\'' => buf.push('\''), - b if (0x20..=0x7e).contains(&b) => buf.push(b as char), - _ => { - let _ = write!(buf, "\\{byte:03o}"); - } - } -} - -pub fn should_display(entry: &DirEntry, config: &Config) -> bool { - // check if hidden - if config.files == Files::Normal && is_hidden(entry) { - return false; - } - - // check if it is among ignore_patterns - let options = MatchOptions { - // setting require_literal_leading_dot to match behavior in GNU ls - require_literal_leading_dot: true, - require_literal_separator: false, - case_sensitive: true, - }; - - let file_name = entry.file_name(); - // If the decoding fails, still match best we can - // FIXME: use OsStrings or Paths once we have a glob crate that supports it: - // https://github.com/rust-lang/glob/issues/23 - // https://github.com/rust-lang/glob/issues/78 - // https://github.com/BurntSushi/ripgrep/issues/1250 - - let file_name = match file_name.to_str() { - Some(s) => Cow::Borrowed(s), - None => file_name.to_string_lossy(), - }; - - !config - .ignore_patterns - .iter() - .any(|p| p.matches_with(&file_name, options)) -} - -fn display_dir_entry_size( - entry: &PathData, - config: &Config, - state: &mut ListState, -) -> (usize, usize, usize, usize, usize, usize) { - // TODO: Cache/memorize the display_* results so we don't have to recalculate them. - if let Some(md) = entry.metadata() { - let (size_len, major_len, minor_len) = match display_len_or_rdev(md, config) { - SizeOrDeviceId::Device(major, minor) => { - (major.len() + minor.len() + 2usize, major.len(), minor.len()) - } - SizeOrDeviceId::Size(size) => (size.len(), 0usize, 0usize), - }; - ( - display_symlink_count(md).len(), - display_uname(md, config, state).len(), - display_group(md, config, state).len(), - size_len, - major_len, - minor_len, - ) - } else { - (0, 0, 0, 0, 0, 0) - } -} - -// A simple, performant, ExtendPad trait to add a string to a Vec, padding with spaces -// on the left or right, without making additional copies, or using formatting functions. -pub trait ExtendPad { - fn extend_pad_left(&mut self, string: &str, count: usize); - fn extend_pad_right(&mut self, string: &str, count: usize); -} - -impl ExtendPad for Vec { - fn extend_pad_left(&mut self, string: &str, count: usize) { - if string.len() < count { - self.extend(iter::repeat_n(b' ', count - string.len())); - } - self.extend(string.as_bytes()); - } - - fn extend_pad_right(&mut self, string: &str, count: usize) { - self.extend(string.as_bytes()); - if string.len() < count { - self.extend(iter::repeat_n(b' ', count - string.len())); - } - } -} - -// TODO: Consider converting callers to use ExtendPad instead, as it avoids -// additional copies. -fn pad_left(string: &str, count: usize) -> String { - format!("{string:>count$}") -} - -#[allow(clippy::cognitive_complexity)] -pub fn display_items( - items: &[PathData], - config: &Config, - state: &mut ListState, - dired: &mut DiredOutput, -) -> UResult<()> { - // `-Z`, `--context`: - // Display the SELinux security context or '?' if none is found. When used with the `-l` - // option, print the security context to the left of the size column. - - let quoted = items.iter().any(|item| { - let name = escape_name_with_locale(item.display_name(), config); - os_str_starts_with(&name, b"'") - }); - - if config.format == Format::Long { - let padding_collection = calculate_padding_collection(items, config, state); - - for item in items { - #[cfg(unix)] - let should_display_leading_info = config.inode || config.alloc_size; - #[cfg(not(unix))] - let should_display_leading_info = config.alloc_size; - - if should_display_leading_info { - let more_info = display_additional_leading_info(item, &padding_collection, config); - - write!(state.out, "{more_info}")?; - } - - display_item_long(item, &padding_collection, config, state, dired, quoted)?; - } - } else { - let mut longest_context_len = 1; - let prefix_context = if config.context { - for item in items { - let context_len = item.security_context(config).len(); - longest_context_len = context_len.max(longest_context_len); - } - Some(longest_context_len) - } else { - None - }; - - let padding = calculate_padding_collection(items, config, state); - - // we need to apply normal color to non filename output - if let Some(style_manager) = &mut state.style_manager { - write!(state.out, "{}", style_manager.apply_normal())?; - } - - let mut names_vec = Vec::new(); - - #[cfg(unix)] - let should_display_leading_info = config.inode || config.alloc_size; - #[cfg(not(unix))] - let should_display_leading_info = config.alloc_size; - - for i in items { - let more_info = if should_display_leading_info { - Some(display_additional_leading_info(i, &padding, config)) - } else { - None - }; - // it's okay to set current column to zero which is used to decide - // whether text will wrap or not, because when format is grid or - // column ls will try to place the item name in a new line if it - // wraps. - let cell = display_item_name( - i, - config, - prefix_context, - more_info, - state, - LazyCell::new(Box::new(|| 0)), - ); - - names_vec.push(cell.displayed); - } - - let mut names = names_vec.into_iter(); - - match config.format { - Format::Columns => { - display_grid( - names, - config.width, - Direction::TopToBottom, - &mut state.out, - quoted, - config.tab_size, - )?; - } - Format::Across => { - display_grid( - names, - config.width, - Direction::LeftToRight, - &mut state.out, - quoted, - config.tab_size, - )?; - } - Format::Commas => { - let mut current_col = 0; - if let Some(name) = names.next() { - write_os_str(&mut state.out, &name)?; - current_col = ansi_width(&name.to_string_lossy()) as u16 + 2; - } - for name in names { - let name_width = ansi_width(&name.to_string_lossy()) as u16; - // If the width is 0 we print one single line - if config.width != 0 && current_col + name_width + 1 > config.width { - current_col = name_width + 2; - writeln!(state.out, ",")?; - } else { - current_col += name_width + 2; - write!(state.out, ", ")?; - } - write_os_str(&mut state.out, &name)?; - } - // Current col is never zero again if names have been printed. - // So we print a newline. - if current_col > 0 { - write!(state.out, "{}", config.line_ending)?; - } - } - _ => { - for name in names { - write_os_str(&mut state.out, &name)?; - write!(state.out, "{}", config.line_ending)?; - } - } - } - } - - Ok(()) -} - -fn display_grid( - names: impl Iterator, - width: u16, - direction: Direction, - out: &mut BufWriter, - quoted: bool, - tab_size: usize, -) -> UResult<()> { - if width == 0 { - // If the width is 0 we print one single line - let mut printed_something = false; - for name in names { - if printed_something { - write!(out, " ")?; - } - printed_something = true; - write_os_str(out, &name)?; - } - if printed_something { - writeln!(out)?; - } - } else { - let names: Vec<_> = if quoted { - // In case some names are quoted, GNU adds a space before each - // entry that does not start with a quote to make it prettier - // on multiline. - // - // Example: - // ``` - // $ ls - // 'a\nb' bar - // foo baz - // ^ ^ - // These spaces is added - // ``` - names - .map(|n| { - if os_str_starts_with(&n, b"'") || os_str_starts_with(&n, b"\"") { - n - } else { - let mut ret: OsString = " ".into(); - ret.push(n); - ret - } - }) - .collect() - } else { - names.collect() - }; - - // FIXME: the Grid crate only supports &str, so can't display raw bytes - let names: Vec<_> = names - .into_iter() - .map(|s| s.to_string_lossy().into_owned()) - .collect(); - - // Since tab_size=0 means no \t, use Spaces separator for optimization. - let filling = match tab_size { - 0 => Filling::Spaces(DEFAULT_SEPARATOR_SIZE), - _ => Filling::Tabs { - spaces: DEFAULT_SEPARATOR_SIZE, - tab_size, - }, - }; - - let grid = Grid::new( - names, - GridOptions { - filling, - direction, - width: width as usize, - }, - ); - write!(out, "{grid}")?; - } - Ok(()) -} - -fn display_additional_leading_info( - item: &PathData, - padding: &PaddingCollection, - config: &Config, -) -> String { - let mut result = String::new(); - #[cfg(unix)] - { - if config.inode { - let i = if let Some(md) = item.metadata() { - get_inode(md) - } else { - "?".to_owned() - }; - write!(result, "{} ", pad_left(&i, padding.inode)).unwrap(); - } - } - - if config.alloc_size { - let s = if let Some(md) = item.metadata() { - display_size(get_block_size(md, config), config) - } else { - "?".to_owned() - }; - // extra space is insert to align the sizes, as needed for all formats, except for the comma format. - if config.format == Format::Commas { - write!(result, "{s} ").unwrap(); - } else { - write!(result, "{} ", pad_left(&s, padding.block_size)).unwrap(); - } - } - - result -} - -fn calculate_line_len(output_len: usize, item_len: usize, line_ending: LineEnding) -> usize { - output_len + item_len + line_ending.to_string().len() -} - -#[cfg(unix)] -fn get_inode(metadata: &Metadata) -> String { - format!("{}", metadata.ino()) -} - -// Currently getpwuid is `linux` target only. If it's broken state.out into -// a posix-compliant attribute this can be updated... -#[cfg(unix)] -fn display_uname<'a>(metadata: &Metadata, config: &Config, state: &'a mut ListState) -> &'a String { - let uid = metadata.uid(); - - state.uid_cache.entry(uid).or_insert_with(|| { - if config.long.numeric_uid_gid { - uid.to_string() - } else { - entries::uid2usr(uid).unwrap_or_else(|_| uid.to_string()) - } - }) -} - -#[cfg(unix)] -fn display_group<'a>(metadata: &Metadata, config: &Config, state: &'a mut ListState) -> &'a String { - let gid = metadata.gid(); - state.gid_cache.entry(gid).or_insert_with(|| { - if config.long.numeric_uid_gid { - gid.to_string() - } else { - entries::gid2grp(gid).unwrap_or_else(|_| gid.to_string()) - } - }) -} - -#[cfg(not(unix))] -fn display_uname(_metadata: &Metadata, _config: &Config, _state: &mut ListState) -> &'static str { - "somebody" -} - -#[cfg(not(unix))] -fn display_group(_metadata: &Metadata, _config: &Config, _state: &mut ListState) -> &'static str { - "somegroup" -} - -fn display_date( - metadata: &Metadata, - config: &Config, - state: &mut ListState, - out: &mut Vec, -) -> UResult<()> { - let Some(time) = metadata_get_time(metadata, config.time) else { - out.extend(b"???"); - return Ok(()); - }; - - // Use "recent" format if the given date is considered recent (i.e., in the last 6 months), - // or if no "older" format is available. - let fmt = match &config.time_format_older { - Some(time_format_older) if !state.recent_time_range.contains(&time) => time_format_older, - _ => &config.time_format_recent, - }; - - format_system_time(out, time, fmt, FormatSystemTimeFallback::Integer) -} - -fn display_len_or_rdev(metadata: &Metadata, config: &Config) -> SizeOrDeviceId { - #[cfg(any( - target_os = "linux", - target_os = "macos", - target_os = "android", - target_os = "ios", - target_os = "freebsd", - target_os = "dragonfly", - target_os = "netbsd", - target_os = "openbsd", - target_os = "illumos", - target_os = "solaris" - ))] - { - let ft = metadata.file_type(); - if ft.is_char_device() || ft.is_block_device() { - // A type cast is needed here as the `dev_t` type varies across OSes. - let dev = metadata.rdev() as dev_t; - let major = major(dev); - let minor = minor(dev); - return SizeOrDeviceId::Device(major.to_string(), minor.to_string()); - } - } - let len_adjusted = { - let d = metadata.len() / config.file_size_block_size; - let r = metadata.len() % config.file_size_block_size; - if r == 0 { d } else { d + 1 } - }; - SizeOrDeviceId::Size(display_size(len_adjusted, config)) -} - -pub fn display_size(size: u64, config: &Config) -> String { - human_readable(size, config.size_format) -} - -/// Takes a [`PathData`] struct and returns a cell with a name ready for displaying. -/// -/// This function relies on the following parameters in the provided `&Config`: -/// * `config.quoting_style` to decide how we will escape `name` using [`locale_aware_escape_name`]. -/// * `config.inode` decides whether to display inode numbers beside names using [`get_inode`]. -/// * `config.color` decides whether it's going to color `name` using [`color_name`]. -/// * `config.indicator_style` to append specific characters to `name` using [`classify_file`]. -/// * `config.format` to display symlink targets if `Format::Long`. This function is also -/// responsible for coloring symlink target names if `config.color` is specified. -/// * `config.context` to prepend security context to `name` if compiled with `feat_selinux`. -/// * `config.hyperlink` decides whether to hyperlink the item -/// -/// Note that non-unicode sequences in symlink targets are dealt with using -/// [`std::path::Path::to_string_lossy`]. -#[allow(clippy::cognitive_complexity)] -fn display_item_name( - path: &PathData, - config: &Config, - prefix_context: Option, - more_info: Option, - state: &mut ListState, - current_column: LazyCell usize + '_>>, -) -> DisplayItemName { - // This is our return value. We start by `&path.display_name` and modify it along the way. - let mut name = escape_name_with_locale(path.display_name(), config); - - let is_wrap = - |namelen: usize| config.width != 0 && *current_column + namelen > config.width.into(); - - if config.hyperlink { - name = create_hyperlink(&name, path); - } - - if let Some(style_manager) = &mut state.style_manager { - let len = name.len(); - name = color_name(name, path, style_manager, None, is_wrap(len)); - } - - if config.format != Format::Long { - if let Some(info) = more_info { - let old_name = name; - name = info.into(); - name.push(&old_name); - } - } - - if config.indicator_style != IndicatorStyle::None { - let sym = classify_file(path); - - let char_opt = match config.indicator_style { - IndicatorStyle::Classify => sym, - IndicatorStyle::FileType => { - // Don't append an asterisk. - match sym { - Some('*') => None, - _ => sym, - } - } - IndicatorStyle::Slash => { - // Append only a slash. - match sym { - Some('/') => Some('/'), - _ => None, - } - } - IndicatorStyle::None => None, - }; - - if let Some(c) = char_opt { - name.push(OsStr::new(&c.to_string())); - } - } - - let dired_name_len = if config.dired { name.len() } else { 0 }; - - if config.format == Format::Long - && path.file_type().is_some_and(FileType::is_symlink) - && !path.must_dereference - { - match path.path().read_link() { - Ok(target_path) => { - name.push(" -> "); - - // We might as well color the symlink output after the arrow. - // This makes extra system calls, but provides important information that - // people run `ls -l --color` are very interested in. - if let Some(style_manager) = &mut state.style_manager { - let escaped_target = escape_name_with_locale(target_path.as_os_str(), config); - // We get the absolute path to be able to construct PathData with valid Metadata. - // This is because relative symlinks will fail to get_metadata. - let mut absolute_target = target_path.clone(); - if target_path.is_relative() { - if let Some(parent) = path.path().parent() { - absolute_target = parent.join(absolute_target); - } - } - - match fs::canonicalize(&absolute_target) { - Ok(resolved_target) => { - let target_data = PathData::new( - resolved_target, - None, - target_path.file_name().map(OsStr::to_os_string), - config, - false, - ); - - // Check if the target actually needs coloring - let md_option: Option = target_data - .metadata() - .cloned() - .or_else(|| target_data.p_buf.symlink_metadata().ok()); - let style = style_manager.colors.style_for_path_with_metadata( - &target_data.p_buf, - md_option.as_ref(), - ); - - if style.is_some() { - // Only apply coloring if there's actually a style - name.push(color_name( - escaped_target, - &target_data, - style_manager, - None, - is_wrap(name.len()), - )); - } else { - // For regular files with no coloring, just use plain text - name.push(escaped_target); - } - } - Err(_) => { - name.push( - style_manager.apply_missing_target_style( - escaped_target, - is_wrap(name.len()), - ), - ); - } - } - } else { - // If no coloring is required, we just use target as is. - // Apply the right quoting - name.push(escape_name_with_locale(target_path.as_os_str(), config)); - } - } - Err(err) => { - show!(LsError::IOErrorContext( - path.path().to_path_buf(), - err, - false - )); - } - } - } - - // Prepend the security context to the `name` and adjust `width` in order - // to get correct alignment from later calls to`display_grid()`. - if config.context { - if let Some(pad_count) = prefix_context { - let security_context = if matches!(config.format, Format::Commas) { - path.security_context(config).to_string() - } else { - pad_left(path.security_context(config), pad_count) - }; - - let old_name = name; - name = format!("{security_context} ").into(); - name.push(old_name); - } - } - - DisplayItemName { - displayed: name, - dired_name_len, - } -} - -/// This writes to the [`BufWriter`] `state.out` a single string of the output of `ls -l`. -/// -/// It writes the following keys, in order: -/// * `inode` ([`get_inode`], config-optional) -/// * `permissions` ([`display_permissions`]) -/// * `symlink_count` ([`display_symlink_count`]) -/// * `owner` ([`display_uname`], config-optional) -/// * `group` ([`display_group`], config-optional) -/// * `author` ([`display_uname`], config-optional) -/// * `size / rdev` ([`display_len_or_rdev`]) -/// * `system_time` ([`display_date`]) -/// * `item_name` ([`display_item_name`]) -/// -/// This function needs to display information in columns: -/// * permissions and `system_time` are already guaranteed to be pre-formatted in fixed length. -/// * `item_name` is the last column and is left-aligned. -/// * Everything else needs to be padded using [`pad_left`]. -/// -/// That's why we have the parameters: -/// ```txt -/// longest_link_count_len: usize, -/// longest_uname_len: usize, -/// longest_group_len: usize, -/// longest_context_len: usize, -/// longest_size_len: usize, -/// ``` -/// that decide the maximum possible character count of each field. -#[allow(clippy::write_literal)] -#[allow(clippy::cognitive_complexity)] -fn display_item_long( - item: &PathData, - padding: &PaddingCollection, - config: &Config, - state: &mut ListState, - dired: &mut DiredOutput, - quoted: bool, -) -> UResult<()> { - let mut output_display: Vec = Vec::with_capacity(128); - - // apply normal color to non filename outputs - if let Some(style_manager) = &mut state.style_manager { - output_display.extend(style_manager.apply_normal().as_bytes()); - } - if config.dired { - output_display.extend(b" "); - } - if let Some(md) = item.metadata() { - #[cfg(any(not(unix), target_os = "android", target_os = "macos"))] - // TODO: See how Mac should work here - let is_acl_set = false; - #[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] - let is_acl_set = has_acl(item.path()); - output_display.extend(display_permissions(md, true).as_bytes()); - if item.security_context(config).len() > 1 { - // GNU `ls` uses a "." character to indicate a file with a security context, - // but not other alternate access method. - output_display.extend(b"."); - } else if is_acl_set { - output_display.extend(b"+"); - } else { - output_display.extend(b" "); - } - - output_display.extend_pad_left(&display_symlink_count(md), padding.link_count); - - if config.long.owner { - output_display.extend(b" "); - output_display.extend_pad_right(display_uname(md, config, state), padding.uname); - } - - if config.long.group { - output_display.extend(b" "); - output_display.extend_pad_right(display_group(md, config, state), padding.group); - } - - if config.context { - output_display.extend(b" "); - output_display.extend_pad_right(item.security_context(config), padding.context); - } - - // Author is only different from owner on GNU/Hurd, so we reuse - // the owner, since GNU/Hurd is not currently supported by Rust. - if config.long.author { - output_display.extend(b" "); - output_display.extend_pad_right(display_uname(md, config, state), padding.uname); - } - - match display_len_or_rdev(md, config) { - SizeOrDeviceId::Size(size) => { - output_display.extend(b" "); - output_display.extend_pad_left(&size, padding.size); - } - SizeOrDeviceId::Device(major, minor) => { - output_display.extend(b" "); - output_display.extend_pad_left( - &major, - #[cfg(not(unix))] - 0usize, - #[cfg(unix)] - padding.major.max( - padding - .size - .saturating_sub(padding.minor.saturating_add(2usize)), - ), - ); - output_display.extend(b", "); - output_display.extend_pad_left( - &minor, - #[cfg(not(unix))] - 0usize, - #[cfg(unix)] - padding.minor, - ); - } - } - - output_display.extend(b" "); - display_date(md, config, state, &mut output_display)?; - output_display.extend(b" "); - - let item_display = display_item_name( - item, - config, - None, - None, - state, - LazyCell::new(Box::new(|| { - ansi_width(&String::from_utf8_lossy(&output_display)) - })), - ); - - let needs_space = quoted && !os_str_starts_with(&item_display.displayed, b"'"); - - if config.dired { - let mut dired_name_len = item_display.dired_name_len; - if needs_space { - dired_name_len += 1; - } - let displayed_len = item_display.displayed.len() + usize::from(needs_space); - update_dired_for_item( - dired, - output_display.len(), - displayed_len, - dired_name_len, - config.line_ending, - ); - } - - let item_name = item_display.displayed; - let displayed_item = if needs_space { - let mut ret: OsString = " ".into(); - ret.push(&item_name); - ret - } else { - item_name - }; - - write_os_str(&mut output_display, &displayed_item)?; - output_display.extend(config.line_ending.to_string().as_bytes()); - } else { - #[cfg(unix)] - let leading_char = { - if let Some(ft) = item.file_type() { - if ft.is_char_device() { - "c" - } else if ft.is_block_device() { - "b" - } else if ft.is_symlink() { - "l" - } else if ft.is_dir() { - "d" - } else { - "-" - } - } else if item.is_dangling_link() { - "l" - } else { - "-" - } - }; - #[cfg(not(unix))] - let leading_char = { - if let Some(ft) = item.file_type() { - if ft.is_symlink() { - "l" - } else if ft.is_dir() { - "d" - } else { - "-" - } - } else if item.is_dangling_link() { - "l" - } else { - "-" - } - }; - - output_display.extend(leading_char.as_bytes()); - output_display.extend(b"?????????"); - if item.security_context(config).len() > 1 { - // GNU `ls` uses a "." character to indicate a file with a security context, - // but not other alternate access method. - output_display.extend(b"."); - } - output_display.extend(b" "); - output_display.extend_pad_left("?", padding.link_count); - - if config.long.owner { - output_display.extend(b" "); - output_display.extend_pad_right("?", padding.uname); - } - - if config.long.group { - output_display.extend(b" "); - output_display.extend_pad_right("?", padding.group); - } - - if config.context { - output_display.extend(b" "); - output_display.extend_pad_right(item.security_context(config), padding.context); - } - - // Author is only different from owner on GNU/Hurd, so we reuse - // the owner, since GNU/Hurd is not currently supported by Rust. - if config.long.author { - output_display.extend(b" "); - output_display.extend_pad_right("?", padding.uname); - } - - let displayed_item = display_item_name( - item, - config, - None, - None, - state, - LazyCell::new(Box::new(|| { - ansi_width(&String::from_utf8_lossy(&output_display)) - })), - ); - let date_len = 12; - - output_display.extend(b" "); - output_display.extend_pad_left("?", padding.size); - output_display.extend(b" "); - output_display.extend_pad_left("?", date_len); - output_display.extend(b" "); - - if config.dired { - update_dired_for_item( - dired, - output_display.len(), - displayed_item.displayed.len(), - displayed_item.dired_name_len, - config.line_ending, - ); - } - let displayed_item = displayed_item.displayed; - write_os_str(&mut output_display, &displayed_item)?; - output_display.extend(config.line_ending.to_string().as_bytes()); - } - state.out.write_all(&output_display)?; - - Ok(()) -} - -fn classify_file(path: &PathData) -> Option { - let file_type = path.file_type()?; - - if file_type.is_dir() { - Some('/') - } else if file_type.is_symlink() { - Some('@') - } else { - #[cfg(unix)] - { - if file_type.is_socket() { - Some('=') - } else if file_type.is_fifo() { - Some('|') - // Safe unwrapping if the file was removed between listing and display - // See https://github.com/uutils/coreutils/issues/5371 - } else if path.is_executable_file() { - Some('*') - } else { - None - } - } - #[cfg(not(unix))] - None - } -} - -fn create_hyperlink(name: &OsStr, path: &PathData) -> OsString { - let hostname = hostname::get().unwrap_or_else(|_| OsString::from("")); - let hostname = hostname.to_string_lossy(); - - let absolute_path = fs::canonicalize(path.path()).unwrap_or_default(); - - // Get bytes for URL encoding in a cross-platform way - let absolute_path_bytes = os_str_as_bytes_lossy(absolute_path.as_os_str()); - - // a set of safe ASCII bytes that don't need encoding - #[cfg(not(target_os = "windows"))] - let unencoded_bytes = b"_-.~/"; - #[cfg(target_os = "windows")] - let unencoded_bytes = b"_-.~/\\:"; - - // Encode at byte level to properly handle UTF-8 sequences and preserve invalid UTF-8 - let full_encoded_path: String = absolute_path_bytes - .iter() - .map(|&b: &u8| { - if b.is_ascii_alphanumeric() || unencoded_bytes.contains(&b) { - (b as char).to_string() - } else { - format!("%{b:02x}") - } - }) - .collect(); - - // OSC 8 hyperlink format: \x1b]8;;URL\x1b\\TEXT\x1b]8;;\x1b\\ - // \x1b = ESC, \x1b\\ = ESC backslash - let mut ret: OsString = format!("\x1b]8;;file://{hostname}{full_encoded_path}\x1b\\").into(); - ret.push(name); - ret.push("\x1b]8;;\x1b\\"); - - ret -} - -fn is_hidden(file_path: &DirEntry) -> bool { - #[cfg(windows)] - { - let metadata = file_path.metadata().unwrap(); - let attr = metadata.file_attributes(); - (attr & 0x2) > 0 - } - #[cfg(not(windows))] - { - file_path.file_name().as_encoded_bytes().starts_with(b".") - } -} - -fn update_dired_for_item( - dired: &mut DiredOutput, - output_display_len: usize, - displayed_len: usize, - dired_name_len: usize, - line_ending: LineEnding, -) { - let line_len = calculate_line_len(output_display_len, displayed_len, line_ending); - dired::calculate_and_update_positions(dired, output_display_len, dired_name_len, line_len); -} - -#[cfg(unix)] -fn display_symlink_count(metadata: &Metadata) -> String { - metadata.nlink().to_string() -} - -#[cfg(unix)] -fn display_inode(metadata: &Metadata) -> String { - get_inode(metadata) -} - -#[cfg(unix)] -fn calculate_padding_collection( - items: &[PathData], - config: &Config, - state: &mut ListState, -) -> PaddingCollection { - let mut padding_collections = PaddingCollection { - inode: 1, - link_count: 1, - uname: 1, - group: 1, - context: 1, - size: 1, - major: 1, - minor: 1, - block_size: 1, - }; - - for item in items { - #[cfg(unix)] - if config.inode { - let inode_len = if let Some(md) = item.metadata() { - display_inode(md).len() - } else { - continue; - }; - padding_collections.inode = inode_len.max(padding_collections.inode); - } - - if config.alloc_size { - if let Some(md) = item.metadata() { - let block_size_len = display_size(get_block_size(md, config), config).len(); - padding_collections.block_size = block_size_len.max(padding_collections.block_size); - } - } - - if config.format == Format::Long { - let context_len = item.security_context(config).len(); - let (link_count_len, uname_len, group_len, size_len, major_len, minor_len) = - display_dir_entry_size(item, config, state); - padding_collections.link_count = link_count_len.max(padding_collections.link_count); - padding_collections.uname = uname_len.max(padding_collections.uname); - padding_collections.group = group_len.max(padding_collections.group); - if config.context { - padding_collections.context = context_len.max(padding_collections.context); - } - - // correctly align columns when some files have capabilities/ACLs and others do not - { - #[cfg(any(not(unix), target_os = "android", target_os = "macos"))] - // TODO: See how Mac should work here - let is_acl_set = false; - #[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] - let is_acl_set = has_acl(item.display_name()); - if context_len > 1 || is_acl_set { - padding_collections.link_count += 1; - } - } - - if items.len() == 1usize { - padding_collections.size = 0usize; - padding_collections.major = 0usize; - padding_collections.minor = 0usize; - } else { - padding_collections.major = major_len.max(padding_collections.major); - padding_collections.minor = minor_len.max(padding_collections.minor); - padding_collections.size = size_len - .max(padding_collections.size) - .max(padding_collections.major); - } - } - } - - padding_collections -} - -#[cfg(not(unix))] -fn display_symlink_count(_metadata: &Metadata) -> String { - // Currently not sure of how to get this on Windows, so I'm punting. - // Git Bash looks like it may do the same thing. - String::from("1") -} - -#[cfg(not(unix))] -fn calculate_padding_collection( - items: &[PathData], - config: &Config, - state: &mut ListState, -) -> PaddingCollection { - let mut padding_collections = PaddingCollection { - link_count: 1, - uname: 1, - group: 1, - context: 1, - size: 1, - block_size: 1, - }; - - for item in items { - if config.alloc_size { - if let Some(md) = item.metadata() { - let block_size_len = display_size(get_block_size(md, config), config).len(); - padding_collections.block_size = block_size_len.max(padding_collections.block_size); - } - } - - let context_len = item.security_context(config).len(); - let (link_count_len, uname_len, group_len, size_len, _major_len, _minor_len) = - display_dir_entry_size(item, config, state); - padding_collections.link_count = link_count_len.max(padding_collections.link_count); - padding_collections.uname = uname_len.max(padding_collections.uname); - padding_collections.group = group_len.max(padding_collections.group); - if config.context { - padding_collections.context = context_len.max(padding_collections.context); - } - padding_collections.size = size_len.max(padding_collections.size); - } - - padding_collections -} - -fn os_str_starts_with(haystack: &OsStr, needle: &[u8]) -> bool { - os_str_as_bytes_lossy(haystack).starts_with(needle) -} - -fn write_os_str(writer: &mut W, string: &OsStr) -> std::io::Result<()> { - writer.write_all(&os_str_as_bytes_lossy(string)) -} diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index 913bc04abd8..748c62de8e8 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -9,55 +9,171 @@ #[cfg(unix)] use rustc_hash::FxHashMap; use rustc_hash::FxHashSet; -use std::borrow::Cow; -use std::cell::RefCell; #[cfg(unix)] use std::os::unix::fs::{FileTypeExt, MetadataExt}; +#[cfg(windows)] +use std::os::windows::fs::MetadataExt; +use std::{borrow::Cow, fs::DirEntry}; use std::{ - cell::OnceCell, + cell::{LazyCell, OnceCell}, cmp::Reverse, ffi::{OsStr, OsString}, - fs::{self, DirEntry, FileType, Metadata, ReadDir}, - io::{BufWriter, ErrorKind, Stdout, Write, stdout}, + fmt::Write as _, + fs::{self, FileType, Metadata, ReadDir}, + io::{BufWriter, ErrorKind, IsTerminal, Stdout, Write, stdout}, + iter, + num::IntErrorKind, ops::RangeInclusive, path::{Path, PathBuf}, time::{Duration, SystemTime, UNIX_EPOCH}, }; +use ansi_width::ansi_width; use clap::{ Arg, ArgAction, Command, builder::{NonEmptyStringValueParser, PossibleValue, ValueParser}, }; -use lscolors::Colorable; +use glob::{MatchOptions, Pattern}; +use lscolors::{Colorable, LsColors}; +use term_grid::{DEFAULT_SEPARATOR_SIZE, Direction, Filling, Grid, GridOptions, SPACES_IN_TAB}; use thiserror::Error; +#[cfg(unix)] +use uucore::entries; +#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +use uucore::fsxattr::has_acl; #[cfg(unix)] use uucore::libc::{S_IXGRP, S_IXOTH, S_IXUSR}; +#[cfg(any( + target_os = "linux", + target_os = "macos", + target_os = "android", + target_os = "ios", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "netbsd", + target_os = "openbsd", + target_os = "illumos", + target_os = "solaris" +))] +use uucore::libc::{dev_t, major, minor}; use uucore::{ display::Quotable, error::{UError, UResult, set_exit_code}, + format::human::{SizeFormat, human_readable}, format_usage, fs::FileInformation, - fsext::metadata_get_time, + fs::display_permissions, + fsext::{MetadataTimeField, metadata_get_time}, + line_ending::LineEnding, os_str_as_bytes_lossy, + parser::parse_glob, + parser::parse_size::parse_size_non_zero_u64, parser::shortcut_value_parser::ShortcutValueParser, - show, translate, + quoting_style::{QuotingStyle, locale_aware_escape_dir_name, locale_aware_escape_name}, + show, show_error, show_warning, + time::{FormatSystemTimeFallback, format, format_system_time}, + translate, version_cmp::version_cmp, }; -mod colors; -mod config; mod dired; -mod display; +use dired::{DiredOutput, is_dired_arg_present}; +mod colors; +use crate::options::QUOTING_STYLE; +use colors::{LsColorsParseError, StyleManager, color_name, validate_ls_colors_env}; + +pub mod options { + pub mod format { + pub static ONE_LINE: &str = "1"; + pub static LONG: &str = "long"; + pub static COLUMNS: &str = "C"; + pub static ACROSS: &str = "x"; + pub static TAB_SIZE: &str = "tabsize"; + pub static COMMAS: &str = "m"; + pub static LONG_NO_OWNER: &str = "g"; + pub static LONG_NO_GROUP: &str = "o"; + pub static LONG_NUMERIC_UID_GID: &str = "numeric-uid-gid"; + } + + pub mod files { + pub static ALL: &str = "all"; + pub static ALMOST_ALL: &str = "almost-all"; + pub static UNSORTED_ALL: &str = "f"; + } + + pub mod sort { + pub static SIZE: &str = "S"; + pub static TIME: &str = "t"; + pub static NONE: &str = "U"; + pub static VERSION: &str = "v"; + pub static EXTENSION: &str = "X"; + } + + pub mod time { + pub static ACCESS: &str = "u"; + pub static CHANGE: &str = "c"; + } + + pub mod size { + pub static ALLOCATION_SIZE: &str = "size"; + pub static BLOCK_SIZE: &str = "block-size"; + pub static HUMAN_READABLE: &str = "human-readable"; + pub static SI: &str = "si"; + pub static KIBIBYTES: &str = "kibibytes"; + } + + pub mod quoting { + pub static ESCAPE: &str = "escape"; + pub static LITERAL: &str = "literal"; + pub static C: &str = "quote-name"; + } + + pub mod indicator_style { + pub static SLASH: &str = "p"; + pub static FILE_TYPE: &str = "file-type"; + pub static CLASSIFY: &str = "classify"; + } -pub use config::{Config, options}; -pub use display::Format; + pub mod dereference { + pub static ALL: &str = "dereference"; + pub static ARGS: &str = "dereference-command-line"; + pub static DIR_ARGS: &str = "dereference-command-line-symlink-to-dir"; + } + + pub static HELP: &str = "help"; + pub static QUOTING_STYLE: &str = "quoting-style"; + pub static HIDE_CONTROL_CHARS: &str = "hide-control-chars"; + pub static SHOW_CONTROL_CHARS: &str = "show-control-chars"; + pub static WIDTH: &str = "width"; + pub static AUTHOR: &str = "author"; + pub static NO_GROUP: &str = "no-group"; + pub static FORMAT: &str = "format"; + pub static SORT: &str = "sort"; + pub static TIME: &str = "time"; + pub static IGNORE_BACKUPS: &str = "ignore-backups"; + pub static DIRECTORY: &str = "directory"; + pub static INODE: &str = "inode"; + pub static REVERSE: &str = "reverse"; + pub static RECURSIVE: &str = "recursive"; + pub static COLOR: &str = "color"; + pub static PATHS: &str = "paths"; + pub static INDICATOR_STYLE: &str = "indicator-style"; + pub static TIME_STYLE: &str = "time-style"; + pub static FULL_TIME: &str = "full-time"; + pub static HIDE: &str = "hide"; + pub static IGNORE: &str = "ignore"; + pub static CONTEXT: &str = "context"; + pub static GROUP_DIRECTORIES_FIRST: &str = "group-directories-first"; + pub static ZERO: &str = "zero"; + pub static DIRED: &str = "dired"; + pub static HYPERLINK: &str = "hyperlink"; +} -use colors::StyleManager; -use config::options::QUOTING_STYLE; -use config::{Dereference, Files, Sort}; -use dired::DiredOutput; -use display::{display_items, display_size, should_display, show_dir_name}; +const DEFAULT_TERM_WIDTH: u16 = 80; +const POSIXLY_CORRECT_BLOCK_SIZE: u64 = 512; +const DEFAULT_BLOCK_SIZE: u64 = 1024; +const DEFAULT_FILE_SIZE_BLOCK_SIZE: u64 = 1; #[derive(Error, Debug)] enum LsError { @@ -113,229 +229,1249 @@ impl UError for LsError { } } -#[uucore::main] -pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let matches = uucore::clap_localization::handle_clap_result_with_exit_code(uu_app(), args, 2)?; +#[derive(PartialEq, Eq, Debug)] +pub enum Format { + Columns, + Long, + OneLine, + Across, + Commas, +} - let config = Config::from(&matches)?; +#[derive(PartialEq, Eq)] +enum Sort { + None, + Name, + Size, + Time, + Version, + Extension, + Width, +} - let locs = matches - .get_many::(options::PATHS) - .map_or_else(|| vec![Path::new(".")], |v| v.map(Path::new).collect()); +#[derive(PartialEq, Eq)] +enum Files { + All, + AlmostAll, + Normal, +} - list(locs, &config) +fn parse_time_style(options: &clap::ArgMatches) -> Result<(String, Option), LsError> { + // TODO: Using correct locale string is not implemented. + const LOCALE_FORMAT: (&str, Option<&str>) = ("%b %e %H:%M", Some("%b %e %Y")); + + // Convert time_styles references to owned String/option. + #[expect(clippy::unnecessary_wraps, reason = "internal result helper")] + fn ok((recent, older): (&str, Option<&str>)) -> Result<(String, Option), LsError> { + Ok((recent.to_string(), older.map(String::from))) + } + + if let Some(field) = options + .get_one::(options::TIME_STYLE) + .map(ToOwned::to_owned) + .or_else(|| std::env::var("TIME_STYLE").ok()) + { + //If both FULL_TIME and TIME_STYLE are present + //The one added last is dominant + if options.get_flag(options::FULL_TIME) + && options.indices_of(options::FULL_TIME).unwrap().next_back() + > options.indices_of(options::TIME_STYLE).unwrap().next_back() + { + ok((format::FULL_ISO, None)) + } else { + let field = if let Some(field) = field.strip_prefix("posix-") { + // See GNU documentation, set format to "locale" if LC_TIME="POSIX", + // else just strip the prefix and continue (even "posix+FORMAT" is + // supported). + // TODO: This needs to be moved to uucore and handled by icu? + if std::env::var_os("LC_TIME").as_deref() == Some(OsStr::new("POSIX")) + || std::env::var_os("LC_ALL").as_deref() == Some(OsStr::new("POSIX")) + { + return ok(LOCALE_FORMAT); + } + field + } else { + &field + }; + + match field { + "full-iso" => ok((format::FULL_ISO, None)), + "long-iso" => ok((format::LONG_ISO, None)), + // ISO older format needs extra padding. + "iso" => Ok(( + "%m-%d %H:%M".to_string(), + Some(format::ISO.to_string() + " "), + )), + "locale" => ok(LOCALE_FORMAT), + _ => match field.chars().next().unwrap() { + '+' => { + // recent/older formats are (optionally) separated by a newline + let mut it = field[1..].split('\n'); + let recent = it.next().unwrap_or_default(); + let older = it.next(); + match it.next() { + None => ok((recent, older)), + Some(_) => Err(LsError::TimeStyleParseError(String::from(field))), + } + } + _ => Err(LsError::TimeStyleParseError(String::from(field))), + }, + } + } + } else if options.get_flag(options::FULL_TIME) { + ok((format::FULL_ISO, None)) + } else { + ok(LOCALE_FORMAT) + } } -pub fn uu_app() -> Command { - uucore::clap_localization::configure_localized_command( - Command::new("ls") - .version(uucore::crate_version!()) - .override_usage(format_usage(&translate!("ls-usage"))) - .about(translate!("ls-about")), - ) - .infer_long_args(true) - .disable_help_flag(true) - .args_override_self(true) - .arg( - Arg::new(options::HELP) - .long(options::HELP) - .help(translate!("ls-help-print-help")) - .action(ArgAction::Help), - ) - // Format arguments - .arg( - Arg::new(options::FORMAT) - .long(options::FORMAT) - .help(translate!("ls-help-set-display-format")) - .value_parser(ShortcutValueParser::new([ - "long", - "verbose", - "single-column", - "columns", - "vertical", - "across", - "horizontal", - "commas", - ])) - .hide_possible_values(true) - .require_equals(true) - .overrides_with_all([ - options::FORMAT, - options::format::COLUMNS, - options::format::LONG, - options::format::ACROSS, - options::format::COLUMNS, - options::DIRED, - ]), - ) - .arg( - Arg::new(options::format::COLUMNS) - .short('C') - .help(translate!("ls-help-display-files-columns")) - .overrides_with_all([ - options::FORMAT, - options::format::COLUMNS, - options::format::LONG, - options::format::ACROSS, - options::format::COLUMNS, - ]) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::format::LONG) - .short('l') - .long(options::format::LONG) - .help(translate!("ls-help-display-detailed-info")) - .overrides_with_all([ - options::FORMAT, - options::format::COLUMNS, - options::format::LONG, - options::format::ACROSS, - options::format::COLUMNS, - ]) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::format::ACROSS) - .short('x') - .help(translate!("ls-help-list-entries-rows")) - .overrides_with_all([ - options::FORMAT, - options::format::COLUMNS, - options::format::LONG, - options::format::ACROSS, - options::format::COLUMNS, - ]) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::format::TAB_SIZE) - .short('T') - .long(options::format::TAB_SIZE) - .env("TABSIZE") - .value_name("COLS") - .help(translate!("ls-help-assume-tab-stops")), - ) - .arg( - Arg::new(options::format::COMMAS) - .short('m') - .help(translate!("ls-help-list-entries-commas")) - .overrides_with_all([ - options::FORMAT, - options::format::COLUMNS, - options::format::LONG, - options::format::ACROSS, - options::format::COLUMNS, - ]) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::ZERO) - .long(options::ZERO) - .overrides_with(options::ZERO) - .help(translate!("ls-help-list-entries-nul")) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::DIRED) - .long(options::DIRED) - .short('D') - .help(translate!("ls-help-generate-dired-output")) - .action(ArgAction::SetTrue) - .overrides_with(options::HYPERLINK), - ) - .arg( - Arg::new(options::HYPERLINK) - .long(options::HYPERLINK) - .help(translate!("ls-help-hyperlink-filenames")) - .value_parser(ShortcutValueParser::new([ - PossibleValue::new("always").alias("yes").alias("force"), - PossibleValue::new("auto").alias("tty").alias("if-tty"), - PossibleValue::new("never").alias("no").alias("none"), - ])) - .require_equals(true) - .num_args(0..=1) - .default_missing_value("always") - .default_value("never") - .value_name("WHEN") - .overrides_with(options::DIRED), - ) - // The next four arguments do not override with the other format - // options, see the comment in Config::from for the reason. - // Ideally, they would use Arg::override_with, with their own name - // but that doesn't seem to work in all cases. Example: - // ls -1g1 - // even though `ls -11` and `ls -1 -g -1` work. - .arg( - Arg::new(options::format::ONE_LINE) - .short('1') - .help(translate!("ls-help-list-one-file-per-line")) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::format::LONG_NO_GROUP) - .short('o') - .help(translate!("ls-help-long-format-no-group")) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::format::LONG_NO_OWNER) - .short('g') - .help(translate!("ls-help-long-no-owner")) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::format::LONG_NUMERIC_UID_GID) - .short('n') - .long(options::format::LONG_NUMERIC_UID_GID) - .help(translate!("ls-help-long-numeric-uid-gid")) - .action(ArgAction::SetTrue), - ) - // Quoting style - .arg( - Arg::new(QUOTING_STYLE) - .long(QUOTING_STYLE) - .help(translate!("ls-help-set-quoting-style")) - .value_parser(ShortcutValueParser::new([ - PossibleValue::new("literal"), - PossibleValue::new("locale"), - PossibleValue::new("shell"), - PossibleValue::new("shell-escape"), - PossibleValue::new("shell-always"), - PossibleValue::new("shell-escape-always"), - PossibleValue::new("clocale"), - PossibleValue::new("c").alias("c-maybe"), - PossibleValue::new("escape"), - ])) - .overrides_with_all([ - QUOTING_STYLE, - options::quoting::LITERAL, - options::quoting::ESCAPE, - options::quoting::C, - ]), - ) - .arg( - Arg::new(options::quoting::LITERAL) - .short('N') - .long(options::quoting::LITERAL) - .alias("l") - .help(translate!("ls-help-literal-quoting-style")) - .overrides_with_all([ - QUOTING_STYLE, - options::quoting::LITERAL, - options::quoting::ESCAPE, - options::quoting::C, - ]) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::quoting::ESCAPE) - .short('b') - .long(options::quoting::ESCAPE) - .help(translate!("ls-help-escape-quoting-style")) - .overrides_with_all([ - QUOTING_STYLE, - options::quoting::LITERAL, +enum Dereference { + None, + DirArgs, + Args, + All, +} + +#[derive(PartialEq, Eq)] +enum IndicatorStyle { + None, + Slash, + FileType, + Classify, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum LocaleQuoting { + Single, + Double, +} + +pub struct Config { + // Dir and vdir needs access to this field + pub format: Format, + files: Files, + sort: Sort, + recursive: bool, + reverse: bool, + dereference: Dereference, + ignore_patterns: Vec, + size_format: SizeFormat, + directory: bool, + time: MetadataTimeField, + #[cfg(unix)] + inode: bool, + color: Option, + long: LongFormat, + alloc_size: bool, + file_size_block_size: u64, + #[allow(dead_code)] + block_size: u64, // is never read on Windows + width: u16, + // Dir and vdir needs access to this field + pub quoting_style: QuotingStyle, + locale_quoting: Option, + indicator_style: IndicatorStyle, + time_format_recent: String, // Time format for recent dates + time_format_older: Option, // Time format for older dates (optional, if not present, time_format_recent is used) + context: bool, + #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] + selinux_supported: bool, + #[cfg(all(feature = "smack", target_os = "linux"))] + smack_supported: bool, + group_directories_first: bool, + line_ending: LineEnding, + dired: bool, + hyperlink: bool, + tab_size: usize, +} + +// Fields that can be removed or added to the long format +struct LongFormat { + author: bool, + group: bool, + owner: bool, + #[cfg(unix)] + numeric_uid_gid: bool, +} + +struct PaddingCollection { + #[cfg(unix)] + inode: usize, + link_count: usize, + uname: usize, + group: usize, + context: usize, + size: usize, + #[cfg(unix)] + major: usize, + #[cfg(unix)] + minor: usize, + block_size: usize, +} + +struct DisplayItemName { + displayed: OsString, + dired_name_len: usize, +} + +/// Extracts the format to display the information based on the options provided. +/// +/// # Returns +/// +/// A tuple containing the Format variant and an Option containing a &'static str +/// which corresponds to the option used to define the format. +fn extract_format(options: &clap::ArgMatches) -> (Format, Option<&'static str>) { + if let Some(format_) = options.get_one::(options::FORMAT) { + ( + match format_.as_str() { + "long" | "verbose" => Format::Long, + "single-column" => Format::OneLine, + "columns" | "vertical" => Format::Columns, + "across" | "horizontal" => Format::Across, + "commas" => Format::Commas, + // below should never happen as clap already restricts the values. + _ => unreachable!("Invalid field for --format"), + }, + Some(options::FORMAT), + ) + } else if options.get_flag(options::format::LONG) { + (Format::Long, Some(options::format::LONG)) + } else if options.get_flag(options::format::ACROSS) { + (Format::Across, Some(options::format::ACROSS)) + } else if options.get_flag(options::format::COMMAS) { + (Format::Commas, Some(options::format::COMMAS)) + } else if options.get_flag(options::format::COLUMNS) { + (Format::Columns, Some(options::format::COLUMNS)) + } else if stdout().is_terminal() { + (Format::Columns, None) + } else { + (Format::OneLine, None) + } +} + +/// Extracts the type of files to display +/// +/// # Returns +/// +/// A Files variant representing the type of files to display. +fn extract_files(options: &clap::ArgMatches) -> Files { + let get_last_index = |flag: &str| -> usize { + if options.value_source(flag) == Some(clap::parser::ValueSource::CommandLine) { + options.index_of(flag).unwrap_or(0) + } else { + 0 + } + }; + + let all_index = get_last_index(options::files::ALL); + let almost_all_index = get_last_index(options::files::ALMOST_ALL); + let unsorted_all_index = get_last_index(options::files::UNSORTED_ALL); + + let max_index = all_index.max(almost_all_index).max(unsorted_all_index); + + if max_index == 0 { + Files::Normal + } else if max_index == almost_all_index { + Files::AlmostAll + } else { + // Either -a or -f wins, both show all files + Files::All + } +} + +/// Extracts the sorting method to use based on the options provided. +/// +/// # Returns +/// +/// A Sort variant representing the sorting method to use. +fn extract_sort(options: &clap::ArgMatches) -> Sort { + let get_last_index = |flag: &str| -> usize { + if options.value_source(flag) == Some(clap::parser::ValueSource::CommandLine) { + options.index_of(flag).unwrap_or(0) + } else { + 0 + } + }; + + let sort_index = options + .get_one::(options::SORT) + .and_then(|_| options.indices_of(options::SORT)) + .map_or(0, |mut indices| indices.next_back().unwrap_or(0)); + let time_index = get_last_index(options::sort::TIME); + let size_index = get_last_index(options::sort::SIZE); + let none_index = get_last_index(options::sort::NONE); + let version_index = get_last_index(options::sort::VERSION); + let extension_index = get_last_index(options::sort::EXTENSION); + let unsorted_all_index = get_last_index(options::files::UNSORTED_ALL); + + let max_sort_index = sort_index + .max(time_index) + .max(size_index) + .max(none_index) + .max(version_index) + .max(extension_index) + .max(unsorted_all_index); + + match max_sort_index { + 0 => { + // No sort flags specified, use default behavior + if !options.get_flag(options::format::LONG) + && (options.get_flag(options::time::ACCESS) + || options.get_flag(options::time::CHANGE) + || options.get_one::(options::TIME).is_some()) + { + Sort::Time + } else { + Sort::Name + } + } + idx if idx == unsorted_all_index || idx == none_index => Sort::None, + idx if idx == sort_index => { + if let Some(field) = options.get_one::(options::SORT) { + match field.as_str() { + "none" => Sort::None, + "name" => Sort::Name, + "time" => Sort::Time, + "size" => Sort::Size, + "version" => Sort::Version, + "extension" => Sort::Extension, + "width" => Sort::Width, + _ => unreachable!("Invalid field for --sort"), + } + } else { + Sort::Name + } + } + idx if idx == time_index => Sort::Time, + idx if idx == size_index => Sort::Size, + idx if idx == version_index => Sort::Version, + idx if idx == extension_index => Sort::Extension, + _ => Sort::Name, + } +} + +/// Extracts the time to use based on the options provided. +/// +/// # Returns +/// +/// A `MetadataTimeField` variant representing the time to use. +fn extract_time(options: &clap::ArgMatches) -> MetadataTimeField { + if let Some(field) = options.get_one::(options::TIME) { + field.as_str().into() + } else if options.get_flag(options::time::ACCESS) { + MetadataTimeField::Access + } else if options.get_flag(options::time::CHANGE) { + MetadataTimeField::Change + } else { + MetadataTimeField::Modification + } +} + +/// Some env variables can be passed +/// For now, we are only verifying if empty or not and known for `TERM` +fn is_color_compatible_term() -> bool { + let is_term_set = std::env::var("TERM").is_ok(); + let is_colorterm_set = std::env::var("COLORTERM").is_ok(); + + let term = std::env::var("TERM").unwrap_or_default(); + let colorterm = std::env::var("COLORTERM").unwrap_or_default(); + + // Search function in the TERM struct to manage the wildcards + let term_matches = |term: &str| -> bool { + uucore::colors::TERMS.iter().any(|&pattern| { + term == pattern + || (pattern.ends_with('*') && term.starts_with(&pattern[..pattern.len() - 1])) + }) + }; + + if is_term_set && term.is_empty() && is_colorterm_set && colorterm.is_empty() { + return false; + } + + if !term.is_empty() && !term_matches(&term) { + return false; + } + true +} + +/// Extracts the color option to use based on the options provided. +/// +/// # Returns +/// +/// A boolean representing whether or not to use color. +fn extract_color(options: &clap::ArgMatches) -> bool { + if !is_color_compatible_term() { + return false; + } + + let get_last_index = |flag: &str| -> usize { + if options.value_source(flag) == Some(clap::parser::ValueSource::CommandLine) { + options.index_of(flag).unwrap_or(0) + } else { + 0 + } + }; + + let color_index = options + .get_one::(options::COLOR) + .and_then(|_| options.indices_of(options::COLOR)) + .map_or(0, |mut indices| indices.next_back().unwrap_or(0)); + let unsorted_all_index = get_last_index(options::files::UNSORTED_ALL); + + let color_enabled = match options.get_one::(options::COLOR) { + None => options.contains_id(options::COLOR), + Some(val) => match val.as_str() { + "" | "always" | "yes" | "force" => true, + "auto" | "tty" | "if-tty" => stdout().is_terminal(), + /* "never" | "no" | "none" | */ _ => false, + }, + }; + + // If --color was explicitly specified, always honor it regardless of -f + // Otherwise, if -f is present without explicit color, disable color + if color_index > 0 { + // Color was explicitly specified + color_enabled + } else if unsorted_all_index > 0 { + // -f present without explicit color, disable implicit color + false + } else { + color_enabled + } +} + +/// Extracts the hyperlink option to use based on the options provided. +/// +/// # Returns +/// +/// A boolean representing whether to hyperlink files. +fn extract_hyperlink(options: &clap::ArgMatches) -> bool { + let hyperlink = options + .get_one::(options::HYPERLINK) + .unwrap() + .as_str(); + + match hyperlink { + "always" | "yes" | "force" => true, + "auto" | "tty" | "if-tty" => stdout().is_terminal(), + "never" | "no" | "none" => false, + _ => unreachable!("should be handled by clap"), + } +} + +/// Match the argument given to --quoting-style or the [`QUOTING_STYLE`] env variable. +/// +/// # Arguments +/// +/// * `style`: the actual argument string +/// * `show_control` - A boolean value representing whether to show control characters. +/// +/// # Returns +/// +/// * An option with None if the style string is invalid, or a `QuotingStyle` wrapped in `Some`. +struct QuotingStyleSpec { + style: QuotingStyle, + fixed_control: bool, + locale: Option, +} + +impl QuotingStyleSpec { + fn new(style: QuotingStyle) -> Self { + Self { + style, + fixed_control: false, + locale: None, + } + } + + fn with_locale(style: QuotingStyle, locale: LocaleQuoting) -> Self { + Self { + style, + fixed_control: true, + locale: Some(locale), + } + } +} + +fn match_quoting_style_name( + style: &str, + show_control: bool, +) -> Option<(QuotingStyle, Option)> { + let spec = match style { + "literal" => QuotingStyleSpec::new(QuotingStyle::Literal { + show_control: false, + }), + "shell" => QuotingStyleSpec::new(QuotingStyle::SHELL), + "shell-always" => QuotingStyleSpec::new(QuotingStyle::SHELL_QUOTE), + "shell-escape" => QuotingStyleSpec::new(QuotingStyle::SHELL_ESCAPE), + "shell-escape-always" => QuotingStyleSpec::new(QuotingStyle::SHELL_ESCAPE_QUOTE), + "c" => QuotingStyleSpec::new(QuotingStyle::C_DOUBLE), + "escape" => QuotingStyleSpec::new(QuotingStyle::C_NO_QUOTES), + "locale" => QuotingStyleSpec { + style: QuotingStyle::Literal { + show_control: false, + }, + fixed_control: true, + locale: Some(LocaleQuoting::Single), + }, + "clocale" => QuotingStyleSpec::with_locale(QuotingStyle::C_DOUBLE, LocaleQuoting::Double), + _ => return None, + }; + + let style = if spec.fixed_control { + spec.style + } else { + spec.style.show_control(show_control) + }; + + Some((style, spec.locale)) +} + +/// Extracts the quoting style to use based on the options provided. +/// If no options are given, it looks if a default quoting style is provided +/// through the [`QUOTING_STYLE`] environment variable. +/// +/// # Arguments +/// +/// * `options` - A reference to a [`clap::ArgMatches`] object containing command line arguments. +/// * `show_control` - A boolean value representing whether or not to show control characters. +/// +/// # Returns +/// +/// A [`QuotingStyle`] variant representing the quoting style to use. +fn extract_quoting_style( + options: &clap::ArgMatches, + show_control: bool, +) -> (QuotingStyle, Option) { + let opt_quoting_style = options.get_one::(QUOTING_STYLE); + + if let Some(style) = opt_quoting_style { + match match_quoting_style_name(style, show_control) { + Some(pair) => pair, + None => unreachable!("Should have been caught by Clap"), + } + } else if options.get_flag(options::quoting::LITERAL) { + (QuotingStyle::Literal { show_control }, None) + } else if options.get_flag(options::quoting::ESCAPE) { + (QuotingStyle::C_NO_QUOTES, None) + } else if options.get_flag(options::quoting::C) { + (QuotingStyle::C_DOUBLE, None) + } else if options.get_flag(options::DIRED) { + (QuotingStyle::Literal { show_control }, None) + } else { + // If set, the QUOTING_STYLE environment variable specifies a default style. + if let Ok(style) = std::env::var("QUOTING_STYLE") { + match match_quoting_style_name(style.as_str(), show_control) { + Some(pair) => return pair, + None => eprintln!( + "{}", + translate!("ls-invalid-quoting-style", "program" => std::env::args().next().unwrap_or_else(|| "ls".to_string()), "style" => style.clone()) + ), + } + } + + // By default, `ls` uses Shell escape quoting style when writing to a terminal file + // descriptor and Literal otherwise. + if stdout().is_terminal() { + (QuotingStyle::SHELL_ESCAPE.show_control(show_control), None) + } else { + (QuotingStyle::Literal { show_control }, None) + } + } +} + +/// Extracts the indicator style to use based on the options provided. +/// +/// # Returns +/// +/// An [`IndicatorStyle`] variant representing the indicator style to use. +fn extract_indicator_style(options: &clap::ArgMatches) -> IndicatorStyle { + if let Some(field) = options.get_one::(options::INDICATOR_STYLE) { + match field.as_str() { + "none" => IndicatorStyle::None, + "file-type" => IndicatorStyle::FileType, + "classify" => IndicatorStyle::Classify, + "slash" => IndicatorStyle::Slash, + &_ => IndicatorStyle::None, + } + } else if let Some(field) = options.get_one::(options::indicator_style::CLASSIFY) { + match field.as_str() { + "never" | "no" | "none" => IndicatorStyle::None, + "always" | "yes" | "force" => IndicatorStyle::Classify, + "auto" | "tty" | "if-tty" => { + if stdout().is_terminal() { + IndicatorStyle::Classify + } else { + IndicatorStyle::None + } + } + &_ => IndicatorStyle::None, + } + } else if options.get_flag(options::indicator_style::SLASH) { + IndicatorStyle::Slash + } else if options.get_flag(options::indicator_style::FILE_TYPE) { + IndicatorStyle::FileType + } else { + IndicatorStyle::None + } +} + +/// Parses the width value from either the command line arguments or the environment variables. +fn parse_width(width_match: Option<&String>) -> Result { + let parse_width_from_args = |s: &str| -> Result { + let radix = if s.starts_with('0') && s.len() > 1 { + 8 + } else { + 10 + }; + match u16::from_str_radix(s, radix) { + Ok(x) => Ok(x), + Err(e) => match e.kind() { + IntErrorKind::PosOverflow => Ok(u16::MAX), + _ => Err(LsError::InvalidLineWidth(s.into())), + }, + } + }; + + let parse_width_from_env = |columns: OsString| { + if let Some(columns) = columns.to_str().and_then(|s| s.parse().ok()) { + columns + } else { + show_error!( + "{}", + translate!("ls-invalid-columns-width", "width" => columns.quote()) + ); + DEFAULT_TERM_WIDTH + } + }; + + let calculate_term_size = || match terminal_size::terminal_size() { + Some((width, _)) => width.0, + None => DEFAULT_TERM_WIDTH, + }; + + let ret = match width_match { + Some(x) => parse_width_from_args(x)?, + None => match std::env::var_os("COLUMNS") { + Some(columns) => parse_width_from_env(columns), + None => calculate_term_size(), + }, + }; + + Ok(ret) +} + +impl Config { + #[allow(clippy::cognitive_complexity)] + pub fn from(options: &clap::ArgMatches) -> UResult { + let context = options.get_flag(options::CONTEXT); + let (mut format, opt) = extract_format(options); + let files = extract_files(options); + + // The -o, -n and -g options are tricky. They cannot override with each + // other because it's possible to combine them. For example, the option + // -og should hide both owner and group. Furthermore, they are not + // reset if -l or --format=long is used. So these should just show the + // group: -gl or "-g --format=long". Finally, they are also not reset + // when switching to a different format option in-between like this: + // -ogCl or "-og --format=vertical --format=long". + // + // -1 has a similar issue: it does nothing if the format is long. This + // actually makes it distinct from the --format=singe-column option, + // which always applies. + // + // The idea here is to not let these options override with the other + // options, but manually whether they have an index that's greater than + // the other format options. If so, we set the appropriate format. + if format != Format::Long { + let idx = opt + .and_then(|opt| options.indices_of(opt).map(|x| x.max().unwrap())) + .unwrap_or(0); + if [ + options::format::LONG_NO_OWNER, + options::format::LONG_NO_GROUP, + options::format::LONG_NUMERIC_UID_GID, + options::FULL_TIME, + ] + .iter() + .filter_map(|opt| { + if options.value_source(opt) == Some(clap::parser::ValueSource::CommandLine) { + options.indices_of(opt) + } else { + None + } + }) + .flatten() + .any(|i| i >= idx) + { + format = Format::Long; + } else if let Some(mut indices) = options.indices_of(options::format::ONE_LINE) { + if options.value_source(options::format::ONE_LINE) + == Some(clap::parser::ValueSource::CommandLine) + && indices.any(|i| i > idx) + { + format = Format::OneLine; + } + } + } + + let sort = extract_sort(options); + let time = extract_time(options); + let mut needs_color = extract_color(options); + let hyperlink = extract_hyperlink(options); + + let opt_block_size = options.get_one::(options::size::BLOCK_SIZE); + let opt_si = opt_block_size.is_some() + && options + .get_one::(options::size::BLOCK_SIZE) + .unwrap() + .eq("si") + || options.get_flag(options::size::SI); + let opt_hr = (opt_block_size.is_some() + && options + .get_one::(options::size::BLOCK_SIZE) + .unwrap() + .eq("human-readable")) + || options.get_flag(options::size::HUMAN_READABLE); + let opt_kb = options.get_flag(options::size::KIBIBYTES); + + let size_format = if opt_si { + SizeFormat::Decimal + } else if opt_hr { + SizeFormat::Binary + } else { + SizeFormat::Bytes + }; + + let env_var_blocksize = std::env::var_os("BLOCKSIZE"); + let env_var_block_size = std::env::var_os("BLOCK_SIZE"); + let env_var_ls_block_size = std::env::var_os("LS_BLOCK_SIZE"); + let env_var_posixly_correct = std::env::var_os("POSIXLY_CORRECT"); + let mut is_env_var_blocksize = false; + + let raw_block_size = if let Some(opt_block_size) = opt_block_size { + OsString::from(opt_block_size) + } else if let Some(env_var_ls_block_size) = env_var_ls_block_size { + env_var_ls_block_size + } else if let Some(env_var_block_size) = env_var_block_size { + env_var_block_size + } else if let Some(env_var_blocksize) = env_var_blocksize { + is_env_var_blocksize = true; + env_var_blocksize + } else { + OsString::from("") + }; + + let (file_size_block_size, block_size) = if !opt_si && !opt_hr && !raw_block_size.is_empty() + { + if let Ok(size) = parse_size_non_zero_u64(&raw_block_size.to_string_lossy()) { + match (is_env_var_blocksize, opt_kb) { + (true, true) => (DEFAULT_FILE_SIZE_BLOCK_SIZE, DEFAULT_BLOCK_SIZE), + (true, false) => (DEFAULT_FILE_SIZE_BLOCK_SIZE, size), + (false, true) => { + // --block-size overrides -k + if opt_block_size.is_some() { + (size, size) + } else { + (size, DEFAULT_BLOCK_SIZE) + } + } + (false, false) => (size, size), + } + } else { + // only fail if invalid block size was specified with --block-size, + // ignore invalid block size from env vars + if let Some(invalid_block_size) = opt_block_size { + return Err(Box::new(LsError::BlockSizeParseError( + invalid_block_size.clone(), + ))); + } + if is_env_var_blocksize { + (DEFAULT_FILE_SIZE_BLOCK_SIZE, DEFAULT_BLOCK_SIZE) + } else { + (DEFAULT_BLOCK_SIZE, DEFAULT_BLOCK_SIZE) + } + } + } else if env_var_posixly_correct.is_some() { + if opt_kb { + (DEFAULT_FILE_SIZE_BLOCK_SIZE, DEFAULT_BLOCK_SIZE) + } else { + (DEFAULT_FILE_SIZE_BLOCK_SIZE, POSIXLY_CORRECT_BLOCK_SIZE) + } + } else if opt_si { + (DEFAULT_FILE_SIZE_BLOCK_SIZE, 1000) + } else { + (DEFAULT_FILE_SIZE_BLOCK_SIZE, DEFAULT_BLOCK_SIZE) + }; + + let long = { + let author = options.get_flag(options::AUTHOR); + let group = !options.get_flag(options::NO_GROUP) + && !options.get_flag(options::format::LONG_NO_GROUP); + let owner = !options.get_flag(options::format::LONG_NO_OWNER); + #[cfg(unix)] + let numeric_uid_gid = options.get_flag(options::format::LONG_NUMERIC_UID_GID); + LongFormat { + author, + group, + owner, + #[cfg(unix)] + numeric_uid_gid, + } + }; + let width = parse_width(options.get_one::(options::WIDTH))?; + + #[allow(clippy::needless_bool)] + let mut show_control = if options.get_flag(options::HIDE_CONTROL_CHARS) { + false + } else if options.get_flag(options::SHOW_CONTROL_CHARS) { + true + } else { + !stdout().is_terminal() + }; + + let (mut quoting_style, mut locale_quoting) = extract_quoting_style(options, show_control); + let indicator_style = extract_indicator_style(options); + // Only parse the value to "--time-style" if it will become relevant. + let dired = options.get_flag(options::DIRED); + let (time_format_recent, time_format_older) = if format == Format::Long || dired { + parse_time_style(options)? + } else { + Default::default() + }; + + let mut ignore_patterns: Vec = Vec::new(); + + if options.get_flag(options::IGNORE_BACKUPS) { + ignore_patterns.push(Pattern::new("*~").unwrap()); + ignore_patterns.push(Pattern::new(".*~").unwrap()); + } + + for pattern in options + .get_many::(options::IGNORE) + .into_iter() + .flatten() + { + if let Ok(p) = parse_glob::from_str(pattern) { + ignore_patterns.push(p); + } else { + show_warning!( + "{}", + translate!("ls-invalid-ignore-pattern", "pattern" => pattern.quote()) + ); + } + } + + if files == Files::Normal { + for pattern in options + .get_many::(options::HIDE) + .into_iter() + .flatten() + { + if let Ok(p) = parse_glob::from_str(pattern) { + ignore_patterns.push(p); + } else { + show_warning!( + "{}", + translate!("ls-invalid-hide-pattern", "pattern" => pattern.quote()) + ); + } + } + } + + // According to ls info page, `--zero` implies the following flags: + // - `--show-control-chars` + // - `--format=single-column` + // - `--color=none` + // - `--quoting-style=literal` + // Current GNU ls implementation allows `--zero` Behavior to be + // overridden by later flags. + let zero_formats_opts = [ + options::format::ACROSS, + options::format::COLUMNS, + options::format::COMMAS, + options::format::LONG, + options::format::LONG_NO_GROUP, + options::format::LONG_NO_OWNER, + options::format::LONG_NUMERIC_UID_GID, + options::format::ONE_LINE, + options::FORMAT, + ]; + let zero_colors_opts = [options::COLOR]; + let zero_show_control_opts = [options::HIDE_CONTROL_CHARS, options::SHOW_CONTROL_CHARS]; + let zero_quoting_style_opts = [ + QUOTING_STYLE, + options::quoting::C, + options::quoting::ESCAPE, + options::quoting::LITERAL, + ]; + let get_last = |flag: &str| -> usize { + if options.value_source(flag) == Some(clap::parser::ValueSource::CommandLine) { + options.index_of(flag).unwrap_or(0) + } else { + 0 + } + }; + if get_last(options::ZERO) + > zero_formats_opts + .into_iter() + .map(get_last) + .max() + .unwrap_or(0) + { + format = if format == Format::Long { + format + } else { + Format::OneLine + }; + } + if get_last(options::ZERO) + > zero_colors_opts + .into_iter() + .map(get_last) + .max() + .unwrap_or(0) + { + needs_color = false; + } + if get_last(options::ZERO) + > zero_show_control_opts + .into_iter() + .map(get_last) + .max() + .unwrap_or(0) + { + show_control = true; + } + if get_last(options::ZERO) + > zero_quoting_style_opts + .into_iter() + .map(get_last) + .max() + .unwrap_or(0) + { + quoting_style = QuotingStyle::Literal { show_control }; + locale_quoting = None; + } + + if needs_color { + if let Err(err) = validate_ls_colors_env() { + if let LsColorsParseError::UnrecognizedPrefix(prefix) = &err { + show_warning!( + "{}", + translate!( + "ls-warning-unrecognized-ls-colors-prefix", + "prefix" => prefix.quote() + ) + ); + } + show_warning!("{}", translate!("ls-warning-unparsable-ls-colors")); + needs_color = false; + } + } + + let color = if needs_color { + Some(LsColors::from_env().unwrap_or_default()) + } else { + None + }; + + if dired || is_dired_arg_present() { + // --dired implies --format=long + // if we have --dired --hyperlink, we don't show dired but we still want to see the + // long format + format = Format::Long; + } + if dired && options.get_flag(options::ZERO) { + return Err(Box::new(LsError::DiredAndZeroAreIncompatible)); + } + + let dereference = if options.get_flag(options::dereference::ALL) { + Dereference::All + } else if options.get_flag(options::dereference::ARGS) { + Dereference::Args + } else if options.get_flag(options::dereference::DIR_ARGS) { + Dereference::DirArgs + } else if options.get_flag(options::DIRECTORY) + || indicator_style == IndicatorStyle::Classify + || format == Format::Long + { + Dereference::None + } else { + Dereference::DirArgs + }; + + let tab_size = if needs_color { + Some(0) + } else { + options + .get_one::(options::format::TAB_SIZE) + .and_then(|size| size.parse::().ok()) + .or_else(|| std::env::var("TABSIZE").ok().and_then(|s| s.parse().ok())) + } + .unwrap_or(SPACES_IN_TAB); + + Ok(Self { + format, + files, + sort, + recursive: options.get_flag(options::RECURSIVE), + reverse: options.get_flag(options::REVERSE), + dereference, + ignore_patterns, + size_format, + directory: options.get_flag(options::DIRECTORY), + time, + color, + #[cfg(unix)] + inode: options.get_flag(options::INODE), + long, + alloc_size: options.get_flag(options::size::ALLOCATION_SIZE), + file_size_block_size, + block_size, + width, + quoting_style, + locale_quoting, + indicator_style, + time_format_recent, + time_format_older, + context, + #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] + selinux_supported: uucore::selinux::is_selinux_enabled(), + #[cfg(all(feature = "smack", target_os = "linux"))] + smack_supported: uucore::smack::is_smack_enabled(), + group_directories_first: options.get_flag(options::GROUP_DIRECTORIES_FIRST), + line_ending: LineEnding::from_zero_flag(options.get_flag(options::ZERO)), + dired, + hyperlink, + tab_size, + }) + } +} + +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let matches = uucore::clap_localization::handle_clap_result_with_exit_code(uu_app(), args, 2)?; + + let config = Config::from(&matches)?; + + let locs = matches + .get_many::(options::PATHS) + .map_or_else(|| vec![Path::new(".")], |v| v.map(Path::new).collect()); + + list(locs, &config) +} + +pub fn uu_app() -> Command { + uucore::clap_localization::configure_localized_command( + Command::new("ls") + .version(uucore::crate_version!()) + .override_usage(format_usage(&translate!("ls-usage"))) + .about(translate!("ls-about")), + ) + .infer_long_args(true) + .disable_help_flag(true) + .args_override_self(true) + .arg( + Arg::new(options::HELP) + .long(options::HELP) + .help(translate!("ls-help-print-help")) + .action(ArgAction::Help), + ) + // Format arguments + .arg( + Arg::new(options::FORMAT) + .long(options::FORMAT) + .help(translate!("ls-help-set-display-format")) + .value_parser(ShortcutValueParser::new([ + "long", + "verbose", + "single-column", + "columns", + "vertical", + "across", + "horizontal", + "commas", + ])) + .hide_possible_values(true) + .require_equals(true) + .overrides_with_all([ + options::FORMAT, + options::format::COLUMNS, + options::format::LONG, + options::format::ACROSS, + options::format::COLUMNS, + options::DIRED, + ]), + ) + .arg( + Arg::new(options::format::COLUMNS) + .short('C') + .help(translate!("ls-help-display-files-columns")) + .overrides_with_all([ + options::FORMAT, + options::format::COLUMNS, + options::format::LONG, + options::format::ACROSS, + options::format::COLUMNS, + ]) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::format::LONG) + .short('l') + .long(options::format::LONG) + .help(translate!("ls-help-display-detailed-info")) + .overrides_with_all([ + options::FORMAT, + options::format::COLUMNS, + options::format::LONG, + options::format::ACROSS, + options::format::COLUMNS, + ]) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::format::ACROSS) + .short('x') + .help(translate!("ls-help-list-entries-rows")) + .overrides_with_all([ + options::FORMAT, + options::format::COLUMNS, + options::format::LONG, + options::format::ACROSS, + options::format::COLUMNS, + ]) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::format::TAB_SIZE) + .short('T') + .long(options::format::TAB_SIZE) + .env("TABSIZE") + .value_name("COLS") + .help(translate!("ls-help-assume-tab-stops")), + ) + .arg( + Arg::new(options::format::COMMAS) + .short('m') + .help(translate!("ls-help-list-entries-commas")) + .overrides_with_all([ + options::FORMAT, + options::format::COLUMNS, + options::format::LONG, + options::format::ACROSS, + options::format::COLUMNS, + ]) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::ZERO) + .long(options::ZERO) + .overrides_with(options::ZERO) + .help(translate!("ls-help-list-entries-nul")) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::DIRED) + .long(options::DIRED) + .short('D') + .help(translate!("ls-help-generate-dired-output")) + .action(ArgAction::SetTrue) + .overrides_with(options::HYPERLINK), + ) + .arg( + Arg::new(options::HYPERLINK) + .long(options::HYPERLINK) + .help(translate!("ls-help-hyperlink-filenames")) + .value_parser(ShortcutValueParser::new([ + PossibleValue::new("always").alias("yes").alias("force"), + PossibleValue::new("auto").alias("tty").alias("if-tty"), + PossibleValue::new("never").alias("no").alias("none"), + ])) + .require_equals(true) + .num_args(0..=1) + .default_missing_value("always") + .default_value("never") + .value_name("WHEN") + .overrides_with(options::DIRED), + ) + // The next four arguments do not override with the other format + // options, see the comment in Config::from for the reason. + // Ideally, they would use Arg::override_with, with their own name + // but that doesn't seem to work in all cases. Example: + // ls -1g1 + // even though `ls -11` and `ls -1 -g -1` work. + .arg( + Arg::new(options::format::ONE_LINE) + .short('1') + .help(translate!("ls-help-list-one-file-per-line")) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::format::LONG_NO_GROUP) + .short('o') + .help(translate!("ls-help-long-format-no-group")) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::format::LONG_NO_OWNER) + .short('g') + .help(translate!("ls-help-long-no-owner")) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::format::LONG_NUMERIC_UID_GID) + .short('n') + .long(options::format::LONG_NUMERIC_UID_GID) + .help(translate!("ls-help-long-numeric-uid-gid")) + .action(ArgAction::SetTrue), + ) + // Quoting style + .arg( + Arg::new(QUOTING_STYLE) + .long(QUOTING_STYLE) + .help(translate!("ls-help-set-quoting-style")) + .value_parser(ShortcutValueParser::new([ + PossibleValue::new("literal"), + PossibleValue::new("locale"), + PossibleValue::new("shell"), + PossibleValue::new("shell-escape"), + PossibleValue::new("shell-always"), + PossibleValue::new("shell-escape-always"), + PossibleValue::new("clocale"), + PossibleValue::new("c").alias("c-maybe"), + PossibleValue::new("escape"), + ])) + .overrides_with_all([ + QUOTING_STYLE, + options::quoting::LITERAL, + options::quoting::ESCAPE, + options::quoting::C, + ]), + ) + .arg( + Arg::new(options::quoting::LITERAL) + .short('N') + .long(options::quoting::LITERAL) + .alias("l") + .help(translate!("ls-help-literal-quoting-style")) + .overrides_with_all([ + QUOTING_STYLE, + options::quoting::LITERAL, + options::quoting::ESCAPE, + options::quoting::C, + ]) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::quoting::ESCAPE) + .short('b') + .long(options::quoting::ESCAPE) + .help(translate!("ls-help-escape-quoting-style")) + .overrides_with_all([ + QUOTING_STYLE, + options::quoting::LITERAL, options::quoting::ESCAPE, options::quoting::C, ]) @@ -784,603 +1920,1644 @@ pub fn uu_app() -> Command { .after_help(translate!("ls-after-help")) } -/// Represents a Path along with it's associated data. -/// Any data that will be reused several times makes sense to be added to this structure. -/// Caching data here helps eliminate redundant syscalls to fetch same information. -#[derive(Debug)] -struct PathData { - // Result got from symlink_metadata() or metadata() based on config - md: OnceCell>, - ft: OnceCell>, - // can be used to avoid reading the filetype. Can be also called d_type: - // https://www.gnu.org/software/libc/manual/html_node/Directory-Entries.html - de: RefCell>>, - security_context: OnceCell>, - // Name of the file - will be empty for . or .. - display_name: OsString, - // PathBuf that all above data corresponds to - p_buf: PathBuf, - must_dereference: bool, - command_line: bool, +/// Represents a Path along with it's associated data. +/// Any data that will be reused several times makes sense to be added to this structure. +/// Caching data here helps eliminate redundant syscalls to fetch same information. +#[derive(Debug)] +struct PathData { + // Result got from symlink_metadata() or metadata() based on config + md: OnceCell>, + ft: OnceCell>, + security_context: OnceCell>, + // Name of the file - will be empty for . or .. + display_name: OsString, + // PathBuf that all above data corresponds to + p_buf: PathBuf, + must_dereference: bool, + command_line: bool, +} + +impl PathData { + fn new( + p_buf: PathBuf, + opt_dir_entry: Option, + opt_file_name: Option, + config: &Config, + command_line: bool, + ) -> Self { + // We cannot use `Path::ends_with` or `Path::Components`, because they remove occurrences of '.' + // For '..', the filename is None + let display_name = if let Some(name) = opt_file_name { + name + } else if command_line { + p_buf.as_os_str().to_os_string() + } else { + p_buf.file_name().unwrap_or_default().to_os_string() + }; + + let must_dereference = match &config.dereference { + Dereference::All => true, + Dereference::Args => command_line, + Dereference::DirArgs => { + if command_line { + if let Ok(md) = p_buf.metadata() { + md.is_dir() + } else { + false + } + } else { + false + } + } + Dereference::None => false, + }; + + let ft: OnceCell> = OnceCell::new(); + let md: OnceCell> = OnceCell::new(); + let security_context: OnceCell> = OnceCell::new(); + + if !must_dereference { + opt_dir_entry.map(|de| ft.get_or_init(|| de.file_type().ok())); + } + + Self { + md, + ft, + security_context, + display_name, + p_buf, + must_dereference, + command_line, + } + } + + fn metadata(&self) -> Option<&Metadata> { + self.md + .get_or_init(|| { + match get_metadata_with_deref_opt(self.path(), self.must_dereference) { + Err(err) => { + // FIXME: A bit tricky to propagate the result here + let mut out: std::io::StdoutLock<'static> = stdout().lock(); + let _ = out.flush(); + let errno = err.raw_os_error().unwrap_or(1i32); + // a bad fd will throw an error when dereferenced, + // but GNU will not throw an error until a bad fd "dir" + // is entered, here we match that GNU behavior, by handing + // back the non-dereferenced metadata upon an EBADF + if self.must_dereference && errno == 9i32 { + if let Ok(file) = self.path().read_link() { + return file.symlink_metadata().ok(); + } + } + show!(LsError::IOErrorContext( + self.path().to_path_buf(), + err, + self.command_line + )); + None + } + Ok(md) => Some(md), + } + }) + .as_ref() + } + + fn file_type(&self) -> Option<&FileType> { + self.ft + .get_or_init(|| self.metadata().map(Metadata::file_type)) + .as_ref() + } + + fn is_dangling_link(&self) -> bool { + // deref enabled, self is real dir entry, self has metadata associated with link, but not with target + self.must_dereference && self.file_type().is_none() && self.metadata().is_none() + } + + #[cfg(unix)] + fn is_executable_file(&self) -> bool { + self.file_type().is_some_and(FileType::is_file) + && self.metadata().is_some_and(file_is_executable) + } + + fn security_context(&self, config: &Config) -> &str { + self.security_context + .get_or_init(|| get_security_context(&self.p_buf, self.must_dereference, config).into()) + } + + fn path(&self) -> &Path { + &self.p_buf + } + + fn display_name(&self) -> &OsStr { + &self.display_name + } +} + +impl Colorable for PathData { + fn file_name(&self) -> OsString { + self.display_name().to_os_string() + } + fn file_type(&self) -> Option { + self.file_type().copied() + } + fn metadata(&self) -> Option { + self.metadata().cloned() + } + fn path(&self) -> PathBuf { + self.path().to_path_buf() + } +} + +/// Show the directory name in the case where several arguments are given to ls +/// or the recursive flag is passed. +/// +/// ```no-exec +/// $ ls -R +/// .: <- This is printed by this function +/// dir1 file1 file2 +/// +/// dir1: <- This as well +/// file11 +/// ``` +fn show_dir_name( + path_data: &PathData, + out: &mut BufWriter, + config: &Config, +) -> std::io::Result<()> { + let escaped_name = escape_dir_name_with_locale(path_data.path().as_os_str(), config); + + let name = if config.hyperlink && !config.dired { + create_hyperlink(&escaped_name, path_data) + } else { + escaped_name + }; + + write_os_str(out, &name)?; + write!(out, ":") +} + +fn escape_with_locale(name: &OsStr, config: &Config, fallback: F) -> OsString +where + F: FnOnce(&OsStr, QuotingStyle) -> OsString, +{ + if let Some(locale) = config.locale_quoting { + locale_quote(name, locale) + } else { + fallback(name, config.quoting_style) + } +} + +fn escape_dir_name_with_locale(name: &OsStr, config: &Config) -> OsString { + escape_with_locale(name, config, locale_aware_escape_dir_name) +} + +fn escape_name_with_locale(name: &OsStr, config: &Config) -> OsString { + escape_with_locale(name, config, locale_aware_escape_name) +} + +fn locale_quote(name: &OsStr, style: LocaleQuoting) -> OsString { + let bytes = os_str_as_bytes_lossy(name); + let mut quoted = String::new(); + match style { + LocaleQuoting::Single => quoted.push('\''), + LocaleQuoting::Double => quoted.push('"'), + } + for &byte in bytes.as_ref() { + push_locale_byte(&mut quoted, byte, style); + } + match style { + LocaleQuoting::Single => quoted.push('\''), + LocaleQuoting::Double => quoted.push('"'), + } + OsString::from(quoted) +} + +fn push_locale_byte(buf: &mut String, byte: u8, style: LocaleQuoting) { + match (style, byte) { + (LocaleQuoting::Single, b'\'') => buf.push_str("'\\''"), + (LocaleQuoting::Double, b'"') => buf.push_str("\\\""), + (_, b'\\') => buf.push_str("\\\\"), + _ => push_basic_escape(buf, byte), + } +} + +fn push_basic_escape(buf: &mut String, byte: u8) { + match byte { + b'\x07' => buf.push_str("\\a"), + b'\x08' => buf.push_str("\\b"), + b'\t' => buf.push_str("\\t"), + b'\n' => buf.push_str("\\n"), + b'\x0b' => buf.push_str("\\v"), + b'\x0c' => buf.push_str("\\f"), + b'\r' => buf.push_str("\\r"), + b'\x1b' => buf.push_str("\\e"), + b'"' => buf.push('"'), + b'\'' => buf.push('\''), + b if (0x20..=0x7e).contains(&b) => buf.push(b as char), + _ => { + let _ = write!(buf, "\\{byte:03o}"); + } + } +} + +type DirData = (PathBuf, bool); + +// A struct to encapsulate state that is passed around from `list` functions. +struct ListState<'a> { + out: BufWriter, + style_manager: Option>, + // TODO: More benchmarking with different use cases is required here. + // From experiments, BTreeMap may be faster than HashMap, especially as the + // number of users/groups is very limited. It seems like nohash::IntMap + // performance was equivalent to BTreeMap. + // It's possible a simple vector linear(binary?) search implementation would be even faster. + #[cfg(unix)] + uid_cache: FxHashMap, + #[cfg(unix)] + gid_cache: FxHashMap, + recent_time_range: RangeInclusive, + stack: Vec, + listed_ancestors: FxHashSet, + initial_locs_len: usize, +} + +#[allow(clippy::cognitive_complexity)] +pub fn list(locs: Vec<&Path>, config: &Config) -> UResult<()> { + let mut files = Vec::::new(); + let mut dirs = Vec::::new(); + let mut dired = DiredOutput::default(); + let initial_locs_len = locs.len(); + + let mut state = ListState { + out: BufWriter::new(stdout()), + style_manager: config.color.as_ref().map(StyleManager::new), + #[cfg(unix)] + uid_cache: FxHashMap::default(), + #[cfg(unix)] + gid_cache: FxHashMap::default(), + // Time range for which to use the "recent" format. Anything from 0.5 year in the past to now + // (files with modification time in the future use "old" format). + // According to GNU a Gregorian year has 365.2425 * 24 * 60 * 60 == 31556952 seconds on the average. + recent_time_range: (SystemTime::now() - Duration::new(31_556_952 / 2, 0)) + ..=SystemTime::now(), + stack: Vec::new(), + listed_ancestors: FxHashSet::default(), + initial_locs_len, + }; + + for loc in locs { + let path_data = PathData::new(PathBuf::from(loc), None, None, config, true); + + // Getting metadata here is no big deal as it's just the CWD + // and we really just want to know if the strings exist as files/dirs + // + // Proper GNU handling is don't show if dereferenced symlink DNE + // but only for the base dir, for a child dir show, and print ?s + // in long format + if path_data.metadata().is_none() { + continue; + } + + let show_dir_contents = if let Some(ft) = path_data.file_type() { + !config.directory && ft.is_dir() + } else { + set_exit_code(1); + false + }; + + if show_dir_contents { + dirs.push(path_data); + } else { + files.push(path_data); + } + } + + sort_entries(&mut files, config); + sort_entries(&mut dirs, config); + + if let Some(style_manager) = state.style_manager.as_mut() { + // ls will try to write a reset before anything is written if normal + // color is given + if style_manager.get_normal_style().is_some() { + let to_write = style_manager.reset(true); + write!(state.out, "{to_write}")?; + } + } + + display_items(&files, config, &mut state, &mut dired)?; + + for (pos, path_data) in dirs.iter().enumerate() { + let needs_blank_line = pos != 0 || !files.is_empty(); + // Do read_dir call here to match GNU semantics by printing + // read_dir errors before directory headings, names and totals + let mut read_dir = match fs::read_dir(path_data.path()) { + Err(err) => { + // flush stdout buffer before the error to preserve formatting and order + state.out.flush()?; + show!(LsError::IOErrorContext( + path_data.path().to_path_buf(), + err, + path_data.command_line + )); + continue; + } + Ok(rd) => rd, + }; + + state.listed_ancestors.insert(FileInformation::from_path( + path_data.path(), + path_data.must_dereference, + )?); + + // List each of the arguments to ls first. + depth_first_list( + (path_data.path().to_path_buf(), needs_blank_line), + &mut read_dir, + config, + &mut state, + &mut dired, + true, + )?; + + // Only runs if it must list recursively. + while let Some(dir_data) = state.stack.pop() { + let mut read_dir = match fs::read_dir(&dir_data.0) { + Err(err) => { + // flush stdout buffer before the error to preserve formatting and order + state.out.flush()?; + show!(LsError::IOErrorContext( + path_data.path().to_path_buf(), + err, + path_data.command_line + )); + continue; + } + Ok(rd) => rd, + }; + + depth_first_list( + dir_data, + &mut read_dir, + config, + &mut state, + &mut dired, + false, + )?; + + // Heuristic to ensure stack does not keep its capacity forever if there is + // combinatorial explosion; we decrease it logarithmically here. + let (cap, len) = (state.stack.capacity(), state.stack.len()); + if cap > (len + 4) * 2 { + state.stack.shrink_to(len + (cap - len) / 2); + } + } + + // No need to clear state.buf since [`enter_directory`] drains it. + state.listed_ancestors.clear(); + } + if config.dired && !config.hyperlink { + dired::print_dired_output(config, &dired, &mut state.out)?; + } + Ok(()) +} + +fn sort_entries(entries: &mut [PathData], config: &Config) { + match config.sort { + Sort::Time => entries.sort_by_key(|k| { + Reverse( + k.metadata() + .and_then(|md| metadata_get_time(md, config.time)) + .unwrap_or(UNIX_EPOCH), + ) + }), + Sort::Size => { + entries.sort_by_key(|k| Reverse(k.metadata().map_or(0, Metadata::len))); + } + // The default sort in GNU ls is case insensitive + Sort::Name => entries.sort_by(|a, b| a.display_name().cmp(b.display_name())), + Sort::Version => entries.sort_by(|a, b| { + version_cmp( + os_str_as_bytes_lossy(a.path().as_os_str()).as_ref(), + os_str_as_bytes_lossy(b.path().as_os_str()).as_ref(), + ) + .then(a.path().to_string_lossy().cmp(&b.path().to_string_lossy())) + }), + Sort::Extension => entries.sort_by(|a, b| { + a.path() + .extension() + .cmp(&b.path().extension()) + .then(a.path().file_stem().cmp(&b.path().file_stem())) + }), + Sort::Width => entries.sort_by(|a, b| { + a.display_name() + .len() + .cmp(&b.display_name().len()) + .then(a.display_name().cmp(b.display_name())) + }), + Sort::None => {} + } + + if config.reverse { + entries.reverse(); + } + + if config.group_directories_first && config.sort != Sort::None { + entries.sort_by_key(|p| { + let ft = { + // We will always try to deref symlinks to group directories, so PathData.md + // is not always useful. + if p.must_dereference { + p.file_type() + } else { + None + } + }; + + !match ft { + None => { + // If it metadata cannot be determined, treat as a file. + get_metadata_with_deref_opt(p.p_buf.as_path(), true) + .map_or_else(|_| false, |m| m.is_dir()) + } + Some(ft) => ft.is_dir(), + } + }); + } +} + +fn is_hidden(path_data: &PathData) -> bool { + #[cfg(windows)] + { + let metadata = path_data.metadata().unwrap(); + let attr = metadata.file_attributes(); + (attr & 0x2) > 0 + } + #[cfg(not(windows))] + { + path_data.file_name().as_encoded_bytes().starts_with(b".") + } +} + +fn should_display(path_data: &PathData, config: &Config) -> bool { + // check if hidden + if config.files == Files::Normal && is_hidden(path_data) { + return false; + } + + // check if it is among ignore_patterns + let options = MatchOptions { + // setting require_literal_leading_dot to match behavior in GNU ls + require_literal_leading_dot: true, + require_literal_separator: false, + case_sensitive: true, + }; + + let file_name = path_data.file_name(); + // If the decoding fails, still match best we can + // FIXME: use OsStrings or Paths once we have a glob crate that supports it: + // https://github.com/rust-lang/glob/issues/23 + // https://github.com/rust-lang/glob/issues/78 + // https://github.com/BurntSushi/ripgrep/issues/1250 + + let file_name_as_cow = file_name.to_string_lossy(); + + !config + .ignore_patterns + .iter() + .any(|p| p.matches_with(&file_name_as_cow, options)) +} + +fn depth_first_list( + (dir_path, needs_blank_line): DirData, + read_dir: &mut ReadDir, + config: &Config, + state: &mut ListState, + dired: &mut DiredOutput, + is_top_level: bool, +) -> UResult<()> { + let path_data = PathData::new(dir_path, None, None, config, false); + + // Print dir heading - name... 'total' comes after error display + if state.initial_locs_len > 1 || config.recursive { + if is_top_level { + if needs_blank_line { + writeln!(state.out)?; + if config.dired { + dired.padding += 1; + } + } + if config.dired { + dired::indent(&mut state.out)?; + } + show_dir_name(&path_data, &mut state.out, config)?; + writeln!(state.out)?; + if config.dired { + let dir_len = path_data.path().as_os_str().len(); + // add the //SUBDIRED// coordinates + dired::calculate_subdired(dired, dir_len); + // Add the padding for the dir name + dired::add_dir_name(dired, dir_len); + } + } else { + writeln!(state.out)?; + if config.dired { + dired.padding += 1; + dired::indent(&mut state.out)?; + let dir_name_size = path_data.path().as_os_str().len(); + dired::calculate_subdired(dired, dir_name_size); + dired::add_dir_name(dired, dir_name_size); + } + show_dir_name(&path_data, &mut state.out, config)?; + writeln!(state.out)?; + } + } + + // Append entries with initial dot files and record their existence + let (ref mut buf, trim) = if config.files == Files::All { + const DOT_DIRECTORIES: usize = 2; + let v = vec![ + PathData::new( + path_data.path().to_path_buf(), + None, + Some(".".into()), + config, + false, + ), + PathData::new( + path_data.path().join(".."), + None, + Some("..".into()), + config, + false, + ), + ]; + (v, DOT_DIRECTORIES) + } else { + (Vec::new(), 0) + }; + + // Convert those entries to the PathData struct + for raw_entry in read_dir { + match raw_entry { + Ok(dir_entry) => { + let path_data = + PathData::new(dir_entry.path(), Some(dir_entry), None, config, false); + if should_display(&path_data, config) { + buf.push(path_data); + } + } + Err(err) => { + state.out.flush()?; + show!(LsError::IOError(err)); + } + } + } + // Relinquish unused space since we won't need it anymore. + buf.shrink_to_fit(); + + sort_entries(buf, config); + + if config.format == Format::Long || config.alloc_size { + let total = return_total(buf, config, &mut state.out)?; + write!(state.out, "{}", total.as_str())?; + if config.dired { + dired::add_total(dired, total.len()); + } + } + + display_items(buf, config, state, dired)?; + + if config.recursive { + for e in buf + .iter() + .skip(trim) + .filter(|p| p.file_type().is_some_and(FileType::is_dir)) + .rev() + { + // Try to open only to report any errors in order to match GNU semantics. + if let Err(err) = fs::read_dir(e.path()) { + state.out.flush()?; + show!(LsError::IOErrorContext( + e.path().to_path_buf(), + err, + e.command_line + )); + } else { + let fi = FileInformation::from_path(e.path(), e.must_dereference)?; + if state.listed_ancestors.insert(fi) { + // Push to stack, but with a less aggressive growth curve. + let (cap, len) = (state.stack.capacity(), state.stack.len()); + if cap == len { + state.stack.reserve_exact(len / 4 + 4); + } + state.stack.push((e.path().to_path_buf(), true)); + } else { + state.out.flush()?; + show!(LsError::AlreadyListedError(e.path().to_path_buf())); + } + } + } + } + Ok(()) +} + +fn get_metadata_with_deref_opt(p_buf: &Path, dereference: bool) -> std::io::Result { + if dereference { + p_buf.metadata() + } else { + p_buf.symlink_metadata() + } } -impl PathData { - fn new( - p_buf: PathBuf, - dir_entry: Option, - file_name: Option, - config: &Config, - command_line: bool, - ) -> Self { - // We cannot use `Path::ends_with` or `Path::Components`, because they remove occurrences of '.' - // For '..', the filename is None - let display_name = if let Some(name) = file_name { - name - } else if command_line { - p_buf.as_os_str().to_os_string() - } else { - dir_entry - .as_ref() - .map(DirEntry::file_name) - .unwrap_or_default() +fn display_dir_entry_size( + entry: &PathData, + config: &Config, + state: &mut ListState, +) -> (usize, usize, usize, usize, usize, usize) { + // TODO: Cache/memorize the display_* results so we don't have to recalculate them. + if let Some(md) = entry.metadata() { + let (size_len, major_len, minor_len) = match display_len_or_rdev(md, config) { + SizeOrDeviceId::Device(major, minor) => { + (major.len() + minor.len() + 2usize, major.len(), minor.len()) + } + SizeOrDeviceId::Size(size) => (size.len(), 0usize, 0usize), }; + ( + display_symlink_count(md).len(), + display_uname(md, config, state).len(), + display_group(md, config, state).len(), + size_len, + major_len, + minor_len, + ) + } else { + (0, 0, 0, 0, 0, 0) + } +} - let must_dereference = match &config.dereference { - Dereference::All => true, - Dereference::Args => command_line, - Dereference::DirArgs => { - if command_line { - if let Ok(md) = p_buf.metadata() { - md.is_dir() - } else { - false - } - } else { - false - } - } - Dereference::None => false, +// A simple, performant, ExtendPad trait to add a string to a Vec, padding with spaces +// on the left or right, without making additional copies, or using formatting functions. +trait ExtendPad { + fn extend_pad_left(&mut self, string: &str, count: usize); + fn extend_pad_right(&mut self, string: &str, count: usize); +} + +impl ExtendPad for Vec { + fn extend_pad_left(&mut self, string: &str, count: usize) { + if string.len() < count { + self.extend(iter::repeat_n(b' ', count - string.len())); + } + self.extend(string.as_bytes()); + } + + fn extend_pad_right(&mut self, string: &str, count: usize) { + self.extend(string.as_bytes()); + if string.len() < count { + self.extend(iter::repeat_n(b' ', count - string.len())); + } + } +} + +// TODO: Consider converting callers to use ExtendPad instead, as it avoids +// additional copies. +fn pad_left(string: &str, count: usize) -> String { + format!("{string:>count$}") +} + +fn return_total( + items: &[PathData], + config: &Config, + out: &mut BufWriter, +) -> UResult { + let mut total_size = 0; + for item in items { + total_size += item + .metadata() + .as_ref() + .map_or(0, |md| get_block_size(md, config)); + } + if config.dired { + dired::indent(out)?; + } + Ok(format!( + "{}{}", + translate!("ls-total", "size" => display_size(total_size, config)), + config.line_ending + )) +} + +fn display_additional_leading_info( + item: &PathData, + padding: &PaddingCollection, + config: &Config, +) -> String { + let mut result = String::new(); + #[cfg(unix)] + { + if config.inode { + let i = if let Some(md) = item.metadata() { + get_inode(md) + } else { + "?".to_owned() + }; + write!(result, "{} ", pad_left(&i, padding.inode)).unwrap(); + } + } + + if config.alloc_size { + let s = if let Some(md) = item.metadata() { + display_size(get_block_size(md, config), config) + } else { + "?".to_owned() }; + // extra space is insert to align the sizes, as needed for all formats, except for the comma format. + if config.format == Format::Commas { + write!(result, "{s} ").unwrap(); + } else { + write!(result, "{} ", pad_left(&s, padding.block_size)).unwrap(); + } + } - // Why prefer to check the DirEntry file_type()? B/c the call is - // nearly free compared to a metadata() call on a Path - let ft: OnceCell> = OnceCell::new(); - let md: OnceCell> = OnceCell::new(); - let security_context: OnceCell> = OnceCell::new(); + result +} - let de: RefCell>> = if let Some(de) = dir_entry { - if must_dereference { - if let Ok(md_pb) = p_buf.metadata() { - md.get_or_init(|| Some(md_pb.clone())); - ft.get_or_init(|| Some(md_pb.file_type())); - } - } +#[allow(clippy::cognitive_complexity)] +fn display_items( + items: &[PathData], + config: &Config, + state: &mut ListState, + dired: &mut DiredOutput, +) -> UResult<()> { + // `-Z`, `--context`: + // Display the SELinux security context or '?' if none is found. When used with the `-l` + // option, print the security context to the left of the size column. + + let quoted = items.iter().any(|item| { + let name = escape_name_with_locale(item.display_name(), config); + os_str_starts_with(&name, b"'") + }); + + if config.format == Format::Long { + let padding_collection = calculate_padding_collection(items, config, state); + + for item in items { + #[cfg(unix)] + let should_display_leading_info = config.inode || config.alloc_size; + #[cfg(not(unix))] + let should_display_leading_info = config.alloc_size; + + if should_display_leading_info { + let more_info = display_additional_leading_info(item, &padding_collection, config); - if let Ok(ft_de) = de.file_type() { - ft.get_or_init(|| Some(ft_de)); + write!(state.out, "{more_info}")?; } - RefCell::new(Some(de.into())) + display_item_long(item, &padding_collection, config, state, dired, quoted)?; + } + } else { + let mut longest_context_len = 1; + let prefix_context = if config.context { + for item in items { + let context_len = item.security_context(config).len(); + longest_context_len = context_len.max(longest_context_len); + } + Some(longest_context_len) } else { - RefCell::new(None) + None }; - Self { - md, - ft, - de, - security_context, - display_name, - p_buf, - must_dereference, - command_line, + let padding = calculate_padding_collection(items, config, state); + + // we need to apply normal color to non filename output + if let Some(style_manager) = &mut state.style_manager { + write!(state.out, "{}", style_manager.apply_normal())?; } - } - fn metadata(&self) -> Option<&Metadata> { - self.md - .get_or_init(|| { - if !self.must_dereference { - if let Some(dir_entry) = RefCell::take(&self.de).as_deref() { - return dir_entry.metadata().ok(); - } - } + let mut names_vec = Vec::new(); - match get_metadata_with_deref_opt(self.path(), self.must_dereference) { - Err(err) => { - // FIXME: A bit tricky to propagate the result here - let mut out: std::io::StdoutLock<'static> = stdout().lock(); - let _ = out.flush(); - let errno = err.raw_os_error().unwrap_or(1i32); - // a bad fd will throw an error when dereferenced, - // but GNU will not throw an error until a bad fd "dir" - // is entered, here we match that GNU behavior, by handing - // back the non-dereferenced metadata upon an EBADF - if self.must_dereference && errno == 9i32 { - if let Ok(file) = self.path().read_link() { - return file.symlink_metadata().ok(); - } - } - show!(LsError::IOErrorContext( - self.path().to_path_buf(), - err, - self.command_line - )); - None + #[cfg(unix)] + let should_display_leading_info = config.inode || config.alloc_size; + #[cfg(not(unix))] + let should_display_leading_info = config.alloc_size; + + for i in items { + let more_info = if should_display_leading_info { + Some(display_additional_leading_info(i, &padding, config)) + } else { + None + }; + // it's okay to set current column to zero which is used to decide + // whether text will wrap or not, because when format is grid or + // column ls will try to place the item name in a new line if it + // wraps. + let cell = display_item_name( + i, + config, + prefix_context, + more_info, + state, + LazyCell::new(Box::new(|| 0)), + ); + + names_vec.push(cell.displayed); + } + + let mut names = names_vec.into_iter(); + + match config.format { + Format::Columns => { + display_grid( + names, + config.width, + Direction::TopToBottom, + &mut state.out, + quoted, + config.tab_size, + )?; + } + Format::Across => { + display_grid( + names, + config.width, + Direction::LeftToRight, + &mut state.out, + quoted, + config.tab_size, + )?; + } + Format::Commas => { + let mut current_col = 0; + if let Some(name) = names.next() { + write_os_str(&mut state.out, &name)?; + current_col = ansi_width(&name.to_string_lossy()) as u16 + 2; + } + for name in names { + let name_width = ansi_width(&name.to_string_lossy()) as u16; + // If the width is 0 we print one single line + if config.width != 0 && current_col + name_width + 1 > config.width { + current_col = name_width + 2; + writeln!(state.out, ",")?; + } else { + current_col += name_width + 2; + write!(state.out, ", ")?; } - Ok(md) => Some(md), + write_os_str(&mut state.out, &name)?; } - }) - .as_ref() - } - - fn file_type(&self) -> Option<&FileType> { - self.ft - .get_or_init(|| self.metadata().map(Metadata::file_type)) - .as_ref() + // Current col is never zero again if names have been printed. + // So we print a newline. + if current_col > 0 { + write!(state.out, "{}", config.line_ending)?; + } + } + _ => { + for name in names { + write_os_str(&mut state.out, &name)?; + write!(state.out, "{}", config.line_ending)?; + } + } + } } - fn is_dangling_link(&self) -> bool { - // deref enabled, self is real dir entry, self has metadata associated with link, but not with target - self.must_dereference && self.file_type().is_none() && self.metadata().is_none() - } + Ok(()) +} +#[allow(unused_variables)] +fn get_block_size(md: &Metadata, config: &Config) -> u64 { + /* GNU ls will display sizes in terms of block size + md.len() will differ from this value when the file has some holes + */ #[cfg(unix)] - fn is_executable_file(&self) -> bool { - self.file_type().is_some_and(FileType::is_file) - && self.metadata().is_some_and(file_is_executable) + { + let raw_blocks = if md.file_type().is_char_device() || md.file_type().is_block_device() { + 0u64 + } else { + md.blocks() * 512 + }; + match config.size_format { + SizeFormat::Binary | SizeFormat::Decimal => raw_blocks, + SizeFormat::Bytes => raw_blocks / config.block_size, + } } - - fn security_context(&self, config: &Config) -> &str { - self.security_context - .get_or_init(|| get_security_context(&self.p_buf, self.must_dereference, config).into()) + #[cfg(not(unix))] + { + // no way to get block size for windows, fall-back to file size + md.len() } +} - fn path(&self) -> &Path { - &self.p_buf - } +fn display_grid( + names: impl Iterator, + width: u16, + direction: Direction, + out: &mut BufWriter, + quoted: bool, + tab_size: usize, +) -> UResult<()> { + if width == 0 { + // If the width is 0 we print one single line + let mut printed_something = false; + for name in names { + if printed_something { + write!(out, " ")?; + } + printed_something = true; + write_os_str(out, &name)?; + } + if printed_something { + writeln!(out)?; + } + } else { + let names: Vec<_> = if quoted { + // In case some names are quoted, GNU adds a space before each + // entry that does not start with a quote to make it prettier + // on multiline. + // + // Example: + // ``` + // $ ls + // 'a\nb' bar + // foo baz + // ^ ^ + // These spaces is added + // ``` + names + .map(|n| { + if os_str_starts_with(&n, b"'") || os_str_starts_with(&n, b"\"") { + n + } else { + let mut ret: OsString = " ".into(); + ret.push(n); + ret + } + }) + .collect() + } else { + names.collect() + }; - fn display_name(&self) -> &OsStr { - &self.display_name + // FIXME: the Grid crate only supports &str, so can't display raw bytes + let names: Vec<_> = names + .into_iter() + .map(|s| s.to_string_lossy().into_owned()) + .collect(); + + // Since tab_size=0 means no \t, use Spaces separator for optimization. + let filling = match tab_size { + 0 => Filling::Spaces(DEFAULT_SEPARATOR_SIZE), + _ => Filling::Tabs { + spaces: DEFAULT_SEPARATOR_SIZE, + tab_size, + }, + }; + + let grid = Grid::new( + names, + GridOptions { + filling, + direction, + width: width as usize, + }, + ); + write!(out, "{grid}")?; } + Ok(()) } -impl Colorable for PathData { - fn file_name(&self) -> OsString { - self.display_name().to_os_string() - } - fn file_type(&self) -> Option { - self.file_type().copied() - } - fn metadata(&self) -> Option { - self.metadata().cloned() +fn calculate_line_len(output_len: usize, item_len: usize, line_ending: LineEnding) -> usize { + output_len + item_len + line_ending.to_string().len() +} + +fn update_dired_for_item( + dired: &mut DiredOutput, + output_display_len: usize, + displayed_len: usize, + dired_name_len: usize, + line_ending: LineEnding, +) { + let line_len = calculate_line_len(output_display_len, displayed_len, line_ending); + dired::calculate_and_update_positions(dired, output_display_len, dired_name_len, line_len); +} + +/// This writes to the [`BufWriter`] `state.out` a single string of the output of `ls -l`. +/// +/// It writes the following keys, in order: +/// * `inode` ([`get_inode`], config-optional) +/// * `permissions` ([`display_permissions`]) +/// * `symlink_count` ([`display_symlink_count`]) +/// * `owner` ([`display_uname`], config-optional) +/// * `group` ([`display_group`], config-optional) +/// * `author` ([`display_uname`], config-optional) +/// * `size / rdev` ([`display_len_or_rdev`]) +/// * `system_time` ([`display_date`]) +/// * `item_name` ([`display_item_name`]) +/// +/// This function needs to display information in columns: +/// * permissions and `system_time` are already guaranteed to be pre-formatted in fixed length. +/// * `item_name` is the last column and is left-aligned. +/// * Everything else needs to be padded using [`pad_left`]. +/// +/// That's why we have the parameters: +/// ```txt +/// longest_link_count_len: usize, +/// longest_uname_len: usize, +/// longest_group_len: usize, +/// longest_context_len: usize, +/// longest_size_len: usize, +/// ``` +/// that decide the maximum possible character count of each field. +#[allow(clippy::write_literal)] +#[allow(clippy::cognitive_complexity)] +fn display_item_long( + item: &PathData, + padding: &PaddingCollection, + config: &Config, + state: &mut ListState, + dired: &mut DiredOutput, + quoted: bool, +) -> UResult<()> { + let mut output_display: Vec = Vec::with_capacity(128); + + // apply normal color to non filename outputs + if let Some(style_manager) = &mut state.style_manager { + output_display.extend(style_manager.apply_normal().as_bytes()); } - fn path(&self) -> PathBuf { - self.path().to_path_buf() + if config.dired { + output_display.extend(b" "); } -} + if let Some(md) = item.metadata() { + #[cfg(any(not(unix), target_os = "android", target_os = "macos"))] + // TODO: See how Mac should work here + let is_acl_set = false; + #[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] + let is_acl_set = has_acl(item.path()); + output_display.extend(display_permissions(md, true).as_bytes()); + if item.security_context(config).len() > 1 { + // GNU `ls` uses a "." character to indicate a file with a security context, + // but not other alternate access method. + output_display.extend(b"."); + } else if is_acl_set { + output_display.extend(b"+"); + } else { + output_display.extend(b" "); + } -type DirData = (PathBuf, bool); + output_display.extend_pad_left(&display_symlink_count(md), padding.link_count); -// A struct to encapsulate state that is passed around from `list` functions. -struct ListState<'a> { - out: BufWriter, - style_manager: Option>, - // TODO: More benchmarking with different use cases is required here. - // From experiments, BTreeMap may be faster than HashMap, especially as the - // number of users/groups is very limited. It seems like nohash::IntMap - // performance was equivalent to BTreeMap. - // It's possible a simple vector linear(binary?) search implementation would be even faster. - #[cfg(unix)] - uid_cache: FxHashMap, - #[cfg(unix)] - gid_cache: FxHashMap, - recent_time_range: RangeInclusive, - stack: Vec, - listed_ancestors: FxHashSet, - initial_locs_len: usize, -} + if config.long.owner { + output_display.extend(b" "); + output_display.extend_pad_right(display_uname(md, config, state), padding.uname); + } -#[allow(clippy::cognitive_complexity)] -pub fn list(locs: Vec<&Path>, config: &Config) -> UResult<()> { - let mut files = Vec::::new(); - let mut dirs = Vec::::new(); - let mut dired = DiredOutput::default(); - let initial_locs_len = locs.len(); + if config.long.group { + output_display.extend(b" "); + output_display.extend_pad_right(display_group(md, config, state), padding.group); + } - let mut state = ListState { - out: BufWriter::new(stdout()), - style_manager: config.color.as_ref().map(StyleManager::new), - #[cfg(unix)] - uid_cache: FxHashMap::default(), - #[cfg(unix)] - gid_cache: FxHashMap::default(), - // Time range for which to use the "recent" format. Anything from 0.5 year in the past to now - // (files with modification time in the future use "old" format). - // According to GNU a Gregorian year has 365.2425 * 24 * 60 * 60 == 31556952 seconds on the average. - recent_time_range: (SystemTime::now() - Duration::new(31_556_952 / 2, 0)) - ..=SystemTime::now(), - stack: Vec::new(), - listed_ancestors: FxHashSet::default(), - initial_locs_len, - }; + if config.context { + output_display.extend(b" "); + output_display.extend_pad_right(item.security_context(config), padding.context); + } - for loc in locs { - let path_data = PathData::new(PathBuf::from(loc), None, None, config, true); + // Author is only different from owner on GNU/Hurd, so we reuse + // the owner, since GNU/Hurd is not currently supported by Rust. + if config.long.author { + output_display.extend(b" "); + output_display.extend_pad_right(display_uname(md, config, state), padding.uname); + } - // Getting metadata here is no big deal as it's just the CWD - // and we really just want to know if the strings exist as files/dirs - // - // Proper GNU handling is don't show if dereferenced symlink DNE - // but only for the base dir, for a child dir show, and print ?s - // in long format - if path_data.metadata().is_none() { - continue; + match display_len_or_rdev(md, config) { + SizeOrDeviceId::Size(size) => { + output_display.extend(b" "); + output_display.extend_pad_left(&size, padding.size); + } + SizeOrDeviceId::Device(major, minor) => { + output_display.extend(b" "); + output_display.extend_pad_left( + &major, + #[cfg(not(unix))] + 0usize, + #[cfg(unix)] + padding.major.max( + padding + .size + .saturating_sub(padding.minor.saturating_add(2usize)), + ), + ); + output_display.extend(b", "); + output_display.extend_pad_left( + &minor, + #[cfg(not(unix))] + 0usize, + #[cfg(unix)] + padding.minor, + ); + } } - let show_dir_contents = if let Some(ft) = path_data.file_type() { - !config.directory && ft.is_dir() - } else { - set_exit_code(1); - false - }; + output_display.extend(b" "); + display_date(md, config, state, &mut output_display)?; + output_display.extend(b" "); - if show_dir_contents { - dirs.push(path_data); - } else { - files.push(path_data); - } - } + let item_display = display_item_name( + item, + config, + None, + None, + state, + LazyCell::new(Box::new(|| { + ansi_width(&String::from_utf8_lossy(&output_display)) + })), + ); - sort_entries(&mut files, config); - sort_entries(&mut dirs, config); + let needs_space = quoted && !os_str_starts_with(&item_display.displayed, b"'"); - if let Some(style_manager) = state.style_manager.as_mut() { - // ls will try to write a reset before anything is written if normal - // color is given - if style_manager.get_normal_style().is_some() { - let to_write = style_manager.reset(true); - write!(state.out, "{to_write}")?; + if config.dired { + let mut dired_name_len = item_display.dired_name_len; + if needs_space { + dired_name_len += 1; + } + let displayed_len = item_display.displayed.len() + usize::from(needs_space); + update_dired_for_item( + dired, + output_display.len(), + displayed_len, + dired_name_len, + config.line_ending, + ); } - } - display_items(&files, config, &mut state, &mut dired)?; + let item_name = item_display.displayed; + let displayed_item = if needs_space { + let mut ret: OsString = " ".into(); + ret.push(&item_name); + ret + } else { + item_name + }; - for (pos, path_data) in dirs.iter().enumerate() { - let needs_blank_line = pos != 0 || !files.is_empty(); - // Do read_dir call here to match GNU semantics by printing - // read_dir errors before directory headings, names and totals - let read_dir = match fs::read_dir(path_data.path()) { - Err(err) => { - // flush stdout buffer before the error to preserve formatting and order - state.out.flush()?; - show!(LsError::IOErrorContext( - path_data.path().to_path_buf(), - err, - path_data.command_line - )); - continue; + write_os_str(&mut output_display, &displayed_item)?; + output_display.extend(config.line_ending.to_string().as_bytes()); + } else { + #[cfg(unix)] + let leading_char = { + if let Some(ft) = item.file_type() { + if ft.is_char_device() { + "c" + } else if ft.is_block_device() { + "b" + } else if ft.is_symlink() { + "l" + } else if ft.is_dir() { + "d" + } else { + "-" + } + } else if item.is_dangling_link() { + "l" + } else { + "-" + } + }; + #[cfg(not(unix))] + let leading_char = { + if let Some(ft) = item.file_type() { + if ft.is_symlink() { + "l" + } else if ft.is_dir() { + "d" + } else { + "-" + } + } else if item.is_dangling_link() { + "l" + } else { + "-" } - Ok(rd) => rd, }; - state.listed_ancestors.insert(FileInformation::from_path( - path_data.path(), - path_data.must_dereference, - )?); + output_display.extend(leading_char.as_bytes()); + output_display.extend(b"?????????"); + if item.security_context(config).len() > 1 { + // GNU `ls` uses a "." character to indicate a file with a security context, + // but not other alternate access method. + output_display.extend(b"."); + } + output_display.extend(b" "); + output_display.extend_pad_left("?", padding.link_count); - // List each of the arguments to ls first. - depth_first_list( - (path_data.path().to_path_buf(), needs_blank_line), - read_dir, - config, - &mut state, - &mut dired, - true, - )?; + if config.long.owner { + output_display.extend(b" "); + output_display.extend_pad_right("?", padding.uname); + } - // Only runs if it must list recursively. - while let Some(dir_data) = state.stack.pop() { - let read_dir = match fs::read_dir(&dir_data.0) { - Err(err) => { - // flush stdout buffer before the error to preserve formatting and order - state.out.flush()?; - show!(LsError::IOErrorContext( - path_data.path().to_path_buf(), - err, - path_data.command_line - )); - continue; - } - Ok(rd) => rd, - }; + if config.long.group { + output_display.extend(b" "); + output_display.extend_pad_right("?", padding.group); + } - depth_first_list(dir_data, read_dir, config, &mut state, &mut dired, false)?; + if config.context { + output_display.extend(b" "); + output_display.extend_pad_right(item.security_context(config), padding.context); + } - // Heuristic to ensure stack does not keep its capacity forever if there is - // combinatorial explosion; we decrease it logarithmically here. - let (cap, len) = (state.stack.capacity(), state.stack.len()); - if cap > (len + 4) * 2 { - state.stack.shrink_to(len + (cap - len) / 2); - } + // Author is only different from owner on GNU/Hurd, so we reuse + // the owner, since GNU/Hurd is not currently supported by Rust. + if config.long.author { + output_display.extend(b" "); + output_display.extend_pad_right("?", padding.uname); } - // No need to clear state.buf since [`enter_directory`] drains it. - state.listed_ancestors.clear(); - } - if config.dired && !config.hyperlink { - dired::print_dired_output(config, &dired, &mut state.out)?; + let displayed_item = display_item_name( + item, + config, + None, + None, + state, + LazyCell::new(Box::new(|| { + ansi_width(&String::from_utf8_lossy(&output_display)) + })), + ); + let date_len = 12; + + output_display.extend(b" "); + output_display.extend_pad_left("?", padding.size); + output_display.extend(b" "); + output_display.extend_pad_left("?", date_len); + output_display.extend(b" "); + + if config.dired { + update_dired_for_item( + dired, + output_display.len(), + displayed_item.displayed.len(), + displayed_item.dired_name_len, + config.line_ending, + ); + } + let displayed_item = displayed_item.displayed; + write_os_str(&mut output_display, &displayed_item)?; + output_display.extend(config.line_ending.to_string().as_bytes()); } + state.out.write_all(&output_display)?; + Ok(()) } -fn sort_entries(entries: &mut [PathData], config: &Config) { - match config.sort { - Sort::Time => entries.sort_by_key(|k| { - Reverse( - k.metadata() - .and_then(|md| metadata_get_time(md, config.time)) - .unwrap_or(UNIX_EPOCH), - ) - }), - Sort::Size => { - entries.sort_by_key(|k| Reverse(k.metadata().map_or(0, Metadata::len))); +#[cfg(unix)] +fn get_inode(metadata: &Metadata) -> String { + format!("{}", metadata.ino()) +} + +// Currently getpwuid is `linux` target only. If it's broken state.out into +// a posix-compliant attribute this can be updated... +#[cfg(unix)] +fn display_uname<'a>(metadata: &Metadata, config: &Config, state: &'a mut ListState) -> &'a String { + let uid = metadata.uid(); + + state.uid_cache.entry(uid).or_insert_with(|| { + if config.long.numeric_uid_gid { + uid.to_string() + } else { + entries::uid2usr(uid).unwrap_or_else(|_| uid.to_string()) } - // The default sort in GNU ls is case insensitive - Sort::Name => entries.sort_by(|a, b| a.display_name().cmp(b.display_name())), - Sort::Version => entries.sort_by(|a, b| { - version_cmp( - os_str_as_bytes_lossy(a.path().as_os_str()).as_ref(), - os_str_as_bytes_lossy(b.path().as_os_str()).as_ref(), - ) - .then(a.path().to_string_lossy().cmp(&b.path().to_string_lossy())) - }), - Sort::Extension => entries.sort_by(|a, b| { - a.path() - .extension() - .cmp(&b.path().extension()) - .then(a.path().file_stem().cmp(&b.path().file_stem())) - }), - Sort::Width => entries.sort_by(|a, b| { - a.display_name() - .len() - .cmp(&b.display_name().len()) - .then(a.display_name().cmp(b.display_name())) - }), - Sort::None => {} - } + }) +} - if config.reverse { - entries.reverse(); +#[cfg(unix)] +fn display_group<'a>(metadata: &Metadata, config: &Config, state: &'a mut ListState) -> &'a String { + let gid = metadata.gid(); + state.gid_cache.entry(gid).or_insert_with(|| { + if config.long.numeric_uid_gid { + gid.to_string() + } else { + entries::gid2grp(gid).unwrap_or_else(|_| gid.to_string()) + } + }) +} + +#[cfg(not(unix))] +fn display_uname(_metadata: &Metadata, _config: &Config, _state: &mut ListState) -> &'static str { + "somebody" +} + +#[cfg(not(unix))] +fn display_group(_metadata: &Metadata, _config: &Config, _state: &mut ListState) -> &'static str { + "somegroup" +} + +fn display_date( + metadata: &Metadata, + config: &Config, + state: &mut ListState, + out: &mut Vec, +) -> UResult<()> { + let Some(time) = metadata_get_time(metadata, config.time) else { + out.extend(b"???"); + return Ok(()); + }; + + // Use "recent" format if the given date is considered recent (i.e., in the last 6 months), + // or if no "older" format is available. + let fmt = match &config.time_format_older { + Some(time_format_older) if !state.recent_time_range.contains(&time) => time_format_older, + _ => &config.time_format_recent, + }; + + format_system_time(out, time, fmt, FormatSystemTimeFallback::Integer) +} + +#[allow(dead_code)] +enum SizeOrDeviceId { + Size(String), + Device(String, String), +} + +fn display_len_or_rdev(metadata: &Metadata, config: &Config) -> SizeOrDeviceId { + #[cfg(any( + target_os = "linux", + target_os = "macos", + target_os = "android", + target_os = "ios", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "netbsd", + target_os = "openbsd", + target_os = "illumos", + target_os = "solaris" + ))] + { + let ft = metadata.file_type(); + if ft.is_char_device() || ft.is_block_device() { + // A type cast is needed here as the `dev_t` type varies across OSes. + let dev = metadata.rdev() as dev_t; + let major = major(dev); + let minor = minor(dev); + return SizeOrDeviceId::Device(major.to_string(), minor.to_string()); + } } + let len_adjusted = { + let d = metadata.len() / config.file_size_block_size; + let r = metadata.len() % config.file_size_block_size; + if r == 0 { d } else { d + 1 } + }; + SizeOrDeviceId::Size(display_size(len_adjusted, config)) +} - if config.group_directories_first && config.sort != Sort::None { - entries.sort_by_key(|p| { - let ft = { - // We will always try to deref symlinks to group directories, so PathData.md - // is not always useful. - if p.must_dereference { - p.file_type() - } else { - None - } - }; +fn display_size(size: u64, config: &Config) -> String { + human_readable(size, config.size_format) +} - !match ft { - None => { - // If it metadata cannot be determined, treat as a file. - get_metadata_with_deref_opt(p.p_buf.as_path(), true) - .map_or_else(|_| false, |m| m.is_dir()) - } - Some(ft) => ft.is_dir(), +#[cfg(unix)] +fn file_is_executable(md: &Metadata) -> bool { + // Mode always returns u32, but the flags might not be, based on the platform + // e.g. linux has u32, mac has u16. + // S_IXUSR -> user has execute permission + // S_IXGRP -> group has execute permission + // S_IXOTH -> other users have execute permission + #[allow(clippy::unnecessary_cast)] + return md.mode() & ((S_IXUSR | S_IXGRP | S_IXOTH) as u32) != 0; +} + +fn classify_file(path: &PathData) -> Option { + let file_type = path.file_type()?; + + if file_type.is_dir() { + Some('/') + } else if file_type.is_symlink() { + Some('@') + } else { + #[cfg(unix)] + { + if file_type.is_socket() { + Some('=') + } else if file_type.is_fifo() { + Some('|') + // Safe unwrapping if the file was removed between listing and display + // See https://github.com/uutils/coreutils/issues/5371 + } else if path.is_executable_file() { + Some('*') + } else { + None } - }); + } + #[cfg(not(unix))] + None } } -fn depth_first_list( - (dir_path, needs_blank_line): DirData, - mut read_dir: ReadDir, +/// Takes a [`PathData`] struct and returns a cell with a name ready for displaying. +/// +/// This function relies on the following parameters in the provided `&Config`: +/// * `config.quoting_style` to decide how we will escape `name` using [`locale_aware_escape_name`]. +/// * `config.inode` decides whether to display inode numbers beside names using [`get_inode`]. +/// * `config.color` decides whether it's going to color `name` using [`color_name`]. +/// * `config.indicator_style` to append specific characters to `name` using [`classify_file`]. +/// * `config.format` to display symlink targets if `Format::Long`. This function is also +/// responsible for coloring symlink target names if `config.color` is specified. +/// * `config.context` to prepend security context to `name` if compiled with `feat_selinux`. +/// * `config.hyperlink` decides whether to hyperlink the item +/// +/// Note that non-unicode sequences in symlink targets are dealt with using +/// [`std::path::Path::to_string_lossy`]. +#[allow(clippy::cognitive_complexity)] +fn display_item_name( + path: &PathData, config: &Config, + prefix_context: Option, + more_info: Option, state: &mut ListState, - dired: &mut DiredOutput, - is_top_level: bool, -) -> UResult<()> { - let path_data = PathData::new(dir_path, None, None, config, false); + current_column: LazyCell usize + '_>>, +) -> DisplayItemName { + // This is our return value. We start by `&path.display_name` and modify it along the way. + let mut name = escape_name_with_locale(path.display_name(), config); - // Print dir heading - name... 'total' comes after error display - if state.initial_locs_len > 1 || config.recursive { - if is_top_level { - if needs_blank_line { - writeln!(state.out)?; - if config.dired { - dired.padding += 1; + let is_wrap = + |namelen: usize| config.width != 0 && *current_column + namelen > config.width.into(); + + if config.hyperlink { + name = create_hyperlink(&name, path); + } + + if let Some(style_manager) = &mut state.style_manager { + let len = name.len(); + name = color_name(name, path, style_manager, None, is_wrap(len)); + } + + if config.format != Format::Long { + if let Some(info) = more_info { + let old_name = name; + name = info.into(); + name.push(&old_name); + } + } + + if config.indicator_style != IndicatorStyle::None { + let sym = classify_file(path); + + let char_opt = match config.indicator_style { + IndicatorStyle::Classify => sym, + IndicatorStyle::FileType => { + // Don't append an asterisk. + match sym { + Some('*') => None, + _ => sym, } } - if config.dired { - dired::indent(&mut state.out)?; - } - show_dir_name(&path_data, &mut state.out, config)?; - writeln!(state.out)?; - if config.dired { - let dir_len = path_data.path().as_os_str().len(); - // add the //SUBDIRED// coordinates - dired::calculate_subdired(dired, dir_len); - // Add the padding for the dir name - dired::add_dir_name(dired, dir_len); - } - } else { - writeln!(state.out)?; - if config.dired { - dired.padding += 1; - dired::indent(&mut state.out)?; - let dir_name_size = path_data.path().as_os_str().len(); - dired::calculate_subdired(dired, dir_name_size); - dired::add_dir_name(dired, dir_name_size); + IndicatorStyle::Slash => { + // Append only a slash. + match sym { + Some('/') => Some('/'), + _ => None, + } } - show_dir_name(&path_data, &mut state.out, config)?; - writeln!(state.out)?; + IndicatorStyle::None => None, + }; + + if let Some(c) = char_opt { + name.push(OsStr::new(&c.to_string())); } } - // Append entries with initial dot files and record their existence - let (ref mut buf, trim) = if config.files == Files::All { - const DOT_DIRECTORIES: usize = 2; - let v = vec![ - PathData::new( - path_data.path().to_path_buf(), - None, - Some(".".into()), - config, - false, - ), - PathData::new( - path_data.path().join(".."), - None, - Some("..".into()), - config, - false, - ), - ]; - (v, DOT_DIRECTORIES) - } else { - (Vec::new(), 0) - }; + let dired_name_len = if config.dired { name.len() } else { 0 }; - // Convert those entries to the PathData struct - for raw_entry in read_dir.by_ref() { - match raw_entry { - Ok(dir_entry) => { - if should_display(&dir_entry, config) { - buf.push(PathData::new( - dir_entry.path(), - Some(dir_entry), - None, - config, - false, - )); + if config.format == Format::Long + && path.file_type().is_some_and(FileType::is_symlink) + && !path.must_dereference + { + match path.path().read_link() { + Ok(target_path) => { + name.push(" -> "); + + // We might as well color the symlink output after the arrow. + // This makes extra system calls, but provides important information that + // people run `ls -l --color` are very interested in. + if let Some(style_manager) = &mut state.style_manager { + let escaped_target = escape_name_with_locale(target_path.as_os_str(), config); + // We get the absolute path to be able to construct PathData with valid Metadata. + // This is because relative symlinks will fail to get_metadata. + let mut absolute_target = target_path.clone(); + if target_path.is_relative() { + if let Some(parent) = path.path().parent() { + absolute_target = parent.join(absolute_target); + } + } + + match fs::canonicalize(&absolute_target) { + Ok(resolved_target) if config.color.is_some() => { + let target_data = PathData::new( + resolved_target, + None, + target_path.file_name().map(OsStr::to_os_string), + config, + false, + ); + + name.push(color_name( + escaped_target, + &target_data, + style_manager, + None, + is_wrap(name.len()), + )); + } + _ => { + name.push( + style_manager.apply_missing_target_style( + escaped_target, + is_wrap(name.len()), + ), + ); + } + } + } else { + // If no coloring is required, we just use target as is. + // Apply the right quoting + name.push(escape_name_with_locale(target_path.as_os_str(), config)); } } Err(err) => { - state.out.flush()?; - show!(LsError::IOError(err)); + show!(LsError::IOErrorContext( + path.path().to_path_buf(), + err, + false + )); } } } - // Relinquish unused space since we won't need it anymore. - buf.shrink_to_fit(); - sort_entries(buf, config); + // Prepend the security context to the `name` and adjust `width` in order + // to get correct alignment from later calls to`display_grid()`. + if config.context { + if let Some(pad_count) = prefix_context { + let security_context = if matches!(config.format, Format::Commas) { + path.security_context(config).to_string() + } else { + pad_left(path.security_context(config), pad_count) + }; - if config.format == Format::Long || config.alloc_size { - let total = return_total(buf, config, &mut state.out)?; - write!(state.out, "{}", total.as_str())?; - if config.dired { - dired::add_total(dired, total.len()); + let old_name = name; + name = format!("{security_context} ").into(); + name.push(old_name); } } - display_items(buf, config, state, dired)?; + DisplayItemName { + displayed: name, + dired_name_len, + } +} - if config.recursive { - for e in buf - .iter() - .skip(trim) - .filter(|p| p.file_type().is_some_and(FileType::is_dir)) - .rev() - { - // Try to open only to report any errors in order to match GNU semantics. - if let Err(err) = fs::read_dir(e.path()) { - state.out.flush()?; - show!(LsError::IOErrorContext( - e.path().to_path_buf(), - err, - e.command_line - )); +fn create_hyperlink(name: &OsStr, path: &PathData) -> OsString { + let hostname = hostname::get().unwrap_or_else(|_| OsString::from("")); + let hostname = hostname.to_string_lossy(); + + let absolute_path = fs::canonicalize(path.path()).unwrap_or_default(); + + // Get bytes for URL encoding in a cross-platform way + let absolute_path_bytes = os_str_as_bytes_lossy(absolute_path.as_os_str()); + + // a set of safe ASCII bytes that don't need encoding + #[cfg(not(target_os = "windows"))] + let unencoded_bytes = b"_-.~/"; + #[cfg(target_os = "windows")] + let unencoded_bytes = b"_-.~/\\:"; + + // Encode at byte level to properly handle UTF-8 sequences and preserve invalid UTF-8 + let full_encoded_path: String = absolute_path_bytes + .iter() + .map(|&b: &u8| { + if b.is_ascii_alphanumeric() || unencoded_bytes.contains(&b) { + (b as char).to_string() } else { - let fi = FileInformation::from_path(e.path(), e.must_dereference)?; - if state.listed_ancestors.insert(fi) { - // Push to stack, but with a less aggressive growth curve. - let (cap, len) = (state.stack.capacity(), state.stack.len()); - if cap == len { - state.stack.reserve_exact(len / 4 + 4); - } - state.stack.push((e.path().to_path_buf(), true)); - } else { - state.out.flush()?; - show!(LsError::AlreadyListedError(e.path().to_path_buf())); - } + format!("%{b:02x}") } - } - } - Ok(()) -} + }) + .collect(); -fn get_metadata_with_deref_opt(p_buf: &Path, dereference: bool) -> std::io::Result { - if dereference { - p_buf.metadata() - } else { - p_buf.symlink_metadata() - } -} + // OSC 8 hyperlink format: \x1b]8;;URL\x1b\\TEXT\x1b]8;;\x1b\\ + // \x1b = ESC, \x1b\\ = ESC backslash + let mut ret: OsString = format!("\x1b]8;;file://{hostname}{full_encoded_path}\x1b\\").into(); + ret.push(name); + ret.push("\x1b]8;;\x1b\\"); -fn return_total( - items: &[PathData], - config: &Config, - out: &mut BufWriter, -) -> UResult { - let mut total_size = 0; - for item in items { - total_size += item - .metadata() - .as_ref() - .map_or(0, |md| get_block_size(md, config)); - } - if config.dired { - dired::indent(out)?; - } - Ok(format!( - "{}{}", - translate!("ls-total", "size" => display_size(total_size, config)), - config.line_ending - )) + ret } -#[allow(unused_variables)] -fn get_block_size(md: &Metadata, config: &Config) -> u64 { - /* GNU ls will display sizes in terms of block size - md.len() will differ from this value when the file has some holes - */ - #[cfg(unix)] - { - use uucore::format::human::SizeFormat; +#[cfg(not(unix))] +fn display_symlink_count(_metadata: &Metadata) -> String { + // Currently not sure of how to get this on Windows, so I'm punting. + // Git Bash looks like it may do the same thing. + String::from("1") +} - let raw_blocks = if md.file_type().is_char_device() || md.file_type().is_block_device() { - 0u64 - } else { - md.blocks() * 512 - }; - match config.size_format { - SizeFormat::Binary | SizeFormat::Decimal => raw_blocks, - SizeFormat::Bytes => raw_blocks / config.block_size, - } - } - #[cfg(not(unix))] - { - // no way to get block size for windows, fall-back to file size - md.len() - } +#[cfg(unix)] +fn display_symlink_count(metadata: &Metadata) -> String { + metadata.nlink().to_string() } #[cfg(unix)] -fn file_is_executable(md: &Metadata) -> bool { - // Mode always returns u32, but the flags might not be, based on the platform - // e.g. linux has u32, mac has u16. - // S_IXUSR -> user has execute permission - // S_IXGRP -> group has execute permission - // S_IXOTH -> other users have execute permission - #[allow(clippy::unnecessary_cast)] - return md.mode() & ((S_IXUSR | S_IXGRP | S_IXOTH) as u32) != 0; +fn display_inode(metadata: &Metadata) -> String { + get_inode(metadata) } /// This returns the `SELinux` security context as UTF8 `String`. @@ -1409,8 +3586,6 @@ fn get_security_context<'a>( #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] if config.selinux_supported { - use uucore::show_warning; - match selinux::SecurityContext::of_path(path, must_dereference, false) { Err(_r) => { // TODO: show the actual reason why it failed @@ -1463,3 +3638,125 @@ fn get_security_context<'a>( Cow::Borrowed(SUBSTITUTE_STRING) } + +#[cfg(unix)] +fn calculate_padding_collection( + items: &[PathData], + config: &Config, + state: &mut ListState, +) -> PaddingCollection { + let mut padding_collections = PaddingCollection { + inode: 1, + link_count: 1, + uname: 1, + group: 1, + context: 1, + size: 1, + major: 1, + minor: 1, + block_size: 1, + }; + + for item in items { + #[cfg(unix)] + if config.inode { + let inode_len = if let Some(md) = item.metadata() { + display_inode(md).len() + } else { + continue; + }; + padding_collections.inode = inode_len.max(padding_collections.inode); + } + + if config.alloc_size { + if let Some(md) = item.metadata() { + let block_size_len = display_size(get_block_size(md, config), config).len(); + padding_collections.block_size = block_size_len.max(padding_collections.block_size); + } + } + + if config.format == Format::Long { + let context_len = item.security_context(config).len(); + let (link_count_len, uname_len, group_len, size_len, major_len, minor_len) = + display_dir_entry_size(item, config, state); + padding_collections.link_count = link_count_len.max(padding_collections.link_count); + padding_collections.uname = uname_len.max(padding_collections.uname); + padding_collections.group = group_len.max(padding_collections.group); + if config.context { + padding_collections.context = context_len.max(padding_collections.context); + } + + // correctly align columns when some files have capabilities/ACLs and others do not + { + #[cfg(any(not(unix), target_os = "android", target_os = "macos"))] + // TODO: See how Mac should work here + let is_acl_set = false; + #[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] + let is_acl_set = has_acl(item.display_name()); + if context_len > 1 || is_acl_set { + padding_collections.link_count += 1; + } + } + + if items.len() == 1usize { + padding_collections.size = 0usize; + padding_collections.major = 0usize; + padding_collections.minor = 0usize; + } else { + padding_collections.major = major_len.max(padding_collections.major); + padding_collections.minor = minor_len.max(padding_collections.minor); + padding_collections.size = size_len + .max(padding_collections.size) + .max(padding_collections.major); + } + } + } + + padding_collections +} + +#[cfg(not(unix))] +fn calculate_padding_collection( + items: &[PathData], + config: &Config, + state: &mut ListState, +) -> PaddingCollection { + let mut padding_collections = PaddingCollection { + link_count: 1, + uname: 1, + group: 1, + context: 1, + size: 1, + block_size: 1, + }; + + for item in items { + if config.alloc_size { + if let Some(md) = item.metadata() { + let block_size_len = display_size(get_block_size(md, config), config).len(); + padding_collections.block_size = block_size_len.max(padding_collections.block_size); + } + } + + let context_len = item.security_context(config).len(); + let (link_count_len, uname_len, group_len, size_len, _major_len, _minor_len) = + display_dir_entry_size(item, config, state); + padding_collections.link_count = link_count_len.max(padding_collections.link_count); + padding_collections.uname = uname_len.max(padding_collections.uname); + padding_collections.group = group_len.max(padding_collections.group); + if config.context { + padding_collections.context = context_len.max(padding_collections.context); + } + padding_collections.size = size_len.max(padding_collections.size); + } + + padding_collections +} + +fn os_str_starts_with(haystack: &OsStr, needle: &[u8]) -> bool { + os_str_as_bytes_lossy(haystack).starts_with(needle) +} + +fn write_os_str(writer: &mut W, string: &OsStr) -> std::io::Result<()> { + writer.write_all(&os_str_as_bytes_lossy(string)) +} diff --git a/src/uucore/src/lib/features/checksum/mod.rs b/src/uucore/src/lib/features/checksum/mod.rs index 9433bbf3682..1f77b7e635f 100644 --- a/src/uucore/src/lib/features/checksum/mod.rs +++ b/src/uucore/src/lib/features/checksum/mod.rs @@ -115,8 +115,6 @@ impl AlgoKind { ALGORITHM_OPTIONS_SHA384 => Sha384, ALGORITHM_OPTIONS_SHA512 => Sha512, - // Extensions not in GNU as of version 9.10 - ALGORITHM_OPTIONS_BLAKE3 => Blake3, ALGORITHM_OPTIONS_SHAKE128 => Shake128, ALGORITHM_OPTIONS_SHAKE256 => Shake256, _ => return Err(ChecksumError::UnknownAlgorithm(algo.as_ref().to_string()).into()), @@ -247,11 +245,11 @@ pub enum SizedAlgoKind { Md5, Sm3, Sha1, + Blake3, Sha2(ShaLength), Sha3(ShaLength), - // Note: we store Blake*'s length as BYTES. + // Note: we store Blake2b's length as BYTES. Blake2b(Option), - Blake3(Option), // Shake* length are stored in bits. Shake128(Option), Shake256(Option), @@ -269,6 +267,7 @@ impl SizedAlgoKind { | ak::Md5 | ak::Sm3 | ak::Sha1 + | ak::Blake3 | ak::Sha224 | ak::Sha256 | ak::Sha384 @@ -283,8 +282,8 @@ impl SizedAlgoKind { (ak::Md5, _) => Ok(Self::Md5), (ak::Sm3, _) => Ok(Self::Sm3), (ak::Sha1, _) => Ok(Self::Sha1), + (ak::Blake3, _) => Ok(Self::Blake3), - (ak::Blake3, l) => Ok(Self::Blake3(l)), (ak::Shake128, l) => Ok(Self::Shake128(l)), (ak::Shake256, l) => Ok(Self::Shake256(l)), (ak::Sha2, Some(l)) => Ok(Self::Sha2(ShaLength::try_from(l)?)), @@ -294,8 +293,7 @@ impl SizedAlgoKind { } // [`calculate_blake2b_length`] expects a length in bits but we // have a length in bytes. - (algo @ ak::Blake2b, Some(l)) => Ok(Self::Blake2b(calculate_blake_length_str( - algo, + (ak::Blake2b, Some(l)) => Ok(Self::Blake2b(calculate_blake2b_length_str( &(8 * l).to_string(), )?)), (ak::Blake2b, None) => Ok(Self::Blake2b(None)), @@ -312,16 +310,11 @@ impl SizedAlgoKind { Self::Md5 => "MD5".into(), Self::Sm3 => "SM3".into(), Self::Sha1 => "SHA1".into(), + Self::Blake3 => "BLAKE3".into(), Self::Sha2(len) => format!("SHA{}", len.as_usize()), Self::Sha3(len) => format!("SHA3-{}", len.as_usize()), Self::Blake2b(Some(byte_len)) => format!("BLAKE2b-{}", byte_len * 8), Self::Blake2b(None) => "BLAKE2b".into(), - Self::Blake3(byte_len) => { - format!( - "BLAKE3-{}", - byte_len.unwrap_or(Blake3::DEFAULT_BYTE_SIZE) * 8 - ) - } Self::Shake128(opt_bit_len) => format!( "SHAKE128-{}", opt_bit_len.unwrap_or(Shake128::DEFAULT_BIT_SIZE) @@ -346,6 +339,7 @@ impl SizedAlgoKind { Self::Md5 => Box::new(Md5::default()), Self::Sm3 => Box::new(Sm3::default()), Self::Sha1 => Box::new(Sha1::default()), + Self::Blake3 => Box::new(Blake3::default()), Self::Sha2(Len224) => Box::new(Sha224::default()), Self::Sha2(Len256) => Box::new(Sha256::default()), Self::Sha2(Len384) => Box::new(Sha384::default()), @@ -357,9 +351,6 @@ impl SizedAlgoKind { Self::Blake2b(len_opt) => { Box::new(len_opt.map(Blake2b::with_output_bytes).unwrap_or_default()) } - Self::Blake3(len_opt) => { - Box::new(len_opt.map(Blake3::with_output_bytes).unwrap_or_default()) - } Self::Shake128(len_opt) => { Box::new(len_opt.map(Shake128::with_output_bits).unwrap_or_default()) } @@ -378,7 +369,7 @@ impl SizedAlgoKind { Self::Md5 => 128, Self::Sm3 => 512, Self::Sha1 => 160, - Self::Blake3(len) => len.unwrap_or(Blake3::DEFAULT_BYTE_SIZE) * 8, + Self::Blake3 => 256, Self::Sha2(len) => len.as_usize(), Self::Sha3(len) => len.as_usize(), Self::Blake2b(len) => len.unwrap_or(Blake2b::DEFAULT_BYTE_SIZE * 8), @@ -495,22 +486,20 @@ pub fn digest_reader( Ok((digest.result(), output_size)) } -/// Calculates the BYTE length of the digest. -pub fn calculate_blake_length_str(algo: AlgoKind, bit_length: &str) -> UResult> { - debug_assert!(matches!(algo, AlgoKind::Blake2b | AlgoKind::Blake3)); - +/// Calculates the length of the digest. +pub fn calculate_blake2b_length_str(bit_length: &str) -> UResult> { // Blake2b's length is parsed in an u64. match bit_length.parse::() { Ok(0) => Ok(None), // Error cases - Ok(n) if n > 512 && algo == AlgoKind::Blake2b => { + Ok(n) if n > 512 => { show_error!("{}", ChecksumError::InvalidLength(bit_length.into())); - Err(ChecksumError::LengthTooBigForBlake(algo.to_uppercase().into()).into()) + Err(ChecksumError::LengthTooBigForBlake("BLAKE2b".into()).into()) } Err(e) if *e.kind() == IntErrorKind::PosOverflow => { show_error!("{}", ChecksumError::InvalidLength(bit_length.into())); - Err(ChecksumError::LengthTooBigForBlake(algo.to_uppercase().into()).into()) + Err(ChecksumError::LengthTooBigForBlake("BLAKE2b".into()).into()) } Err(_) => Err(ChecksumError::InvalidLength(bit_length.into()).into()), @@ -643,19 +632,10 @@ mod tests { #[test] fn test_calculate_blake2b_length() { - assert_eq!( - calculate_blake_length_str(AlgoKind::Blake2b, "0").unwrap(), - None - ); - assert!(calculate_blake_length_str(AlgoKind::Blake2b, "10").is_err()); - assert!(calculate_blake_length_str(AlgoKind::Blake2b, "520").is_err()); - assert_eq!( - calculate_blake_length_str(AlgoKind::Blake2b, "512").unwrap(), - None - ); - assert_eq!( - calculate_blake_length_str(AlgoKind::Blake2b, "256").unwrap(), - Some(32) - ); + assert_eq!(calculate_blake2b_length_str("0").unwrap(), None); + assert!(calculate_blake2b_length_str("10").is_err()); + assert!(calculate_blake2b_length_str("520").is_err()); + assert_eq!(calculate_blake2b_length_str("512").unwrap(), None); + assert_eq!(calculate_blake2b_length_str("256").unwrap(), Some(32)); } } diff --git a/src/uucore/src/lib/features/checksum/validate.rs b/src/uucore/src/lib/features/checksum/validate.rs index fcfba47e8c0..17b1c7bf522 100644 --- a/src/uucore/src/lib/features/checksum/validate.rs +++ b/src/uucore/src/lib/features/checksum/validate.rs @@ -614,7 +614,6 @@ fn identify_algo_name_and_length( algo_name_input: Option, last_algo: &mut Option, ) -> Result<(AlgoKind, Option), LineCheckError> { - use AlgoKind as ak; let algo_from_line = line_info.algo_name.clone().unwrap_or_default(); let Ok(line_algo) = AlgoKind::from_cksum(algo_from_line.to_lowercase()) else { // Unknown algorithm @@ -630,20 +629,24 @@ fn identify_algo_name_and_length( match (algo_name_input, line_algo) { (l, r) if l == r => (), // Edge case for SHA2, which matches SHA(224|256|384|512) - (ak::Sha2, ak::Sha224 | ak::Sha256 | ak::Sha384 | ak::Sha512) => (), + ( + AlgoKind::Sha2, + AlgoKind::Sha224 | AlgoKind::Sha256 | AlgoKind::Sha384 | AlgoKind::Sha512, + ) => (), _ => return Err(LineCheckError::ImproperlyFormatted), } } let bytes = if let Some(bitlen) = line_info.algo_bit_len { match line_algo { - ak::Blake2b | ak::Blake3 if bitlen % 8 == 0 => Some(bitlen / 8), - ak::Sha2 | ak::Sha3 if [224, 256, 384, 512].contains(&bitlen) => Some(bitlen), - ak::Shake128 | ak::Shake256 => Some(bitlen), + AlgoKind::Blake2b if bitlen % 8 == 0 => Some(bitlen / 8), + AlgoKind::Sha2 | AlgoKind::Sha3 if [224, 256, 384, 512].contains(&bitlen) => { + Some(bitlen) + } + AlgoKind::Shake128 | AlgoKind::Shake256 => Some(bitlen), // Either - // the algo based line is provided with a bit length with an - // algorithm that does not support it (only Blake2b, Blake3, sha2, - // and sha3 do). + // the algo based line is provided with a bit length + // with an algorithm that does not support it (only Blake2B does). // // eg: MD5-128 (foo.txt) = fffffffff // ^ This is illegal @@ -651,12 +654,9 @@ fn identify_algo_name_and_length( // the given length is wrong because it's not a multiple of 8. _ => return Err(LineCheckError::ImproperlyFormatted), } - } else if line_algo == ak::Blake2b { + } else if line_algo == AlgoKind::Blake2b { // Default length with BLAKE2b, Some(64) - } else if line_algo == ak::Blake3 { - // Default length with BLAKE3, - Some(32) } else { None }; @@ -741,7 +741,7 @@ fn process_algo_based_line( // If the digest bitlen is known, we can check the format of the expected // checksum with it. let digest_char_length_hint = match (algo_kind, algo_byte_len) { - (AlgoKind::Blake2b | AlgoKind::Blake3, Some(byte_len)) => Some(byte_len), + (AlgoKind::Blake2b, Some(byte_len)) => Some(byte_len), (AlgoKind::Shake128 | AlgoKind::Shake256, Some(bit_len)) => Some(bit_len.div_ceil(8)), (AlgoKind::Shake128, None) => Some(sum::Shake128::DEFAULT_BIT_SIZE.div_ceil(8)), (AlgoKind::Shake256, None) => Some(sum::Shake256::DEFAULT_BIT_SIZE.div_ceil(8)), @@ -764,7 +764,6 @@ fn process_non_algo_based_line( cli_algo_length: Option, opts: ChecksumValidateOptions, ) -> Result<(), LineCheckError> { - use AlgoKind as ak; let mut filename_to_check = line_info.filename.as_slice(); if filename_to_check.starts_with(b"*") && line_number == 0 @@ -779,16 +778,16 @@ fn process_non_algo_based_line( // When a specific algorithm name is input, use it and use the provided // bits except when dealing with blake2b, sha2 and sha3, where we will // detect the length. - let algo_byte_len = match cli_algo_kind { - ak::Blake2b | ak::Blake3 => Some(expected_checksum.len()), - ak::Sha2 | ak::Sha3 => { + let (algo_kind, algo_byte_len) = match cli_algo_kind { + AlgoKind::Blake2b => (AlgoKind::Blake2b, Some(expected_checksum.len())), + algo @ (AlgoKind::Sha2 | AlgoKind::Sha3) => { // multiplication by 8 to get the number of bits - Some(expected_checksum.len() * 8) + (algo, Some(expected_checksum.len() * 8)) } - _ => cli_algo_length, + _ => (cli_algo_kind, cli_algo_length), }; - let algo = SizedAlgoKind::from_unsized(cli_algo_kind, algo_byte_len)?; + let algo = SizedAlgoKind::from_unsized(algo_kind, algo_byte_len)?; compute_and_check_digest_from_file(filename_to_check, &expected_checksum, algo, opts) } diff --git a/src/uucore/src/lib/features/sum.rs b/src/uucore/src/lib/features/sum.rs index af122ced1a3..279643a962b 100644 --- a/src/uucore/src/lib/features/sum.rs +++ b/src/uucore/src/lib/features/sum.rs @@ -122,48 +122,25 @@ impl Digest for Blake2b { } } -pub struct Blake3 { - digest: blake3::Hasher, - byte_size: usize, -} - -impl Blake3 { - /// Default length for the BLAKE3 digest in bytes. - pub const DEFAULT_BYTE_SIZE: usize = 32; - - pub fn with_output_bytes(output_bytes: usize) -> Self { - Self { - digest: blake3::Hasher::new(), - byte_size: output_bytes, - } - } -} - -impl Default for Blake3 { - fn default() -> Self { - Self { - digest: blake3::Hasher::default(), - byte_size: Self::DEFAULT_BYTE_SIZE, - } - } -} +#[derive(Default)] +pub struct Blake3(blake3::Hasher); impl Digest for Blake3 { fn hash_update(&mut self, input: &[u8]) { - self.digest.update(input); + self.0.update(input); } fn hash_finalize(&mut self, out: &mut [u8]) { - let mut hash_result = self.digest.finalize_xof(); - hash_result.fill(out); + let hash_result = &self.0.finalize(); + out.copy_from_slice(hash_result.as_bytes()); } fn reset(&mut self) { - *self = Self::with_output_bytes(self.output_bytes()); + *self = Self::default(); } fn output_bits(&self) -> usize { - self.byte_size * 8 + 256 } } diff --git a/tests/by-util/test_cksum.rs b/tests/by-util/test_cksum.rs index 1ab5bb45285..67f96e90762 100644 --- a/tests/by-util/test_cksum.rs +++ b/tests/by-util/test_cksum.rs @@ -5,7 +5,6 @@ // spell-checker:ignore (words) asdf algo algos asha mgmt xffname hexa GFYEQ HYQK Yqxb dont checkfile use rstest::rstest; -use rstest_reuse::{apply, template}; use uutests::at_and_ucmd; use uutests::new_ucmd; @@ -3288,112 +3287,3 @@ fn test_check_shake256_no_length() { .fails() .stderr_only("cksum: 'standard input': no properly formatted checksum lines found\n"); } - -#[template] -#[rstest] -#[case::no_length( - b"foo", - "04e0bb39f30b1a3feb89f536c93be15055482df748674b00d26e5a75777702e9", - None -)] -#[case( - b"foo", - "04e0bb39f30b1a3feb89f536c93be15055482df748674b00d26e5a75777702e9", - Some(0) -)] -#[case( - b"foo", - "04e0bb39f30b1a3feb89f536c93be15055482df748674b00d26e5a75777702e9", - Some(256) -)] -#[case( - b"foo", - "04e0bb39f30b1a3feb89f536c93be15055482df748674b00d26e5a75777702e9791074b7511b59d31c71c62f5a745689fa6c", - Some(400) -)] -#[case(b"foo", "04e0bb39f3", Some(40))] -#[case(b"foo", "04e0", Some(16))] -#[case(b"foo", "04", Some(8))] -fn test_blake3(#[case] input: &[u8], #[case] expected: &str, #[case] length: Option) {} - -#[apply(test_blake3)] -fn test_compute_blake3( - #[case] input: &[u8], - #[case] expected: &str, - #[case] length: Option, -) { - let length_args: &[String] = if let Some(len) = length { - &["-l".into(), len.to_string()] - } else { - &[] - }; - - new_ucmd!() - .arg("-a") - .arg("blake3") - .args(length_args) - .pipe_in(input) - .succeeds() - .stdout_only(format!( - "BLAKE3{} (-) = {expected}\n", - match length { - Some(0) | None => "-256".into(), - Some(i) => format!("-{i}"), - } - )); - - // with --untagged - new_ucmd!() - .arg("-a") - .arg("blake3") - .arg("--untagged") - .args(length_args) - .pipe_in(input) - .succeeds() - .stdout_only(format!("{expected} -\n")); -} - -#[apply(test_blake3)] -fn test_check_blake3_tagged( - #[case] input: &[u8], - #[case] digest: &str, - #[case] opt_len: Option, -) { - let (at, mut ucmd) = at_and_ucmd!(); - at.write_bytes("FILE", input); - - let len = match opt_len { - Some(0) => "-256".into(), - Some(i) => format!("-{i}"), - None => String::new(), - }; - - let tagged = format!("BLAKE3{len} (FILE) = {digest}",); - - ucmd.arg("-c") - .arg("-a") - .arg("blake3") - .pipe_in(tagged) - .succeeds() - .stdout_only("FILE: OK\n"); -} - -#[apply(test_blake3)] -#[allow(clippy::used_underscore_binding)] -fn test_check_blake3_untagged( - #[case] input: &[u8], - #[case] digest: &str, - #[case] _opt_len: Option, -) { - let (at, mut ucmd) = at_and_ucmd!(); - at.write_bytes("FILE", input); - - let untagged = format!("{digest} FILE"); - - ucmd.arg("-c") - .arg("-a") - .arg("blake3") - .pipe_in(untagged) - .succeeds() - .stdout_only("FILE: OK\n"); -} diff --git a/util/run-gnu-tests-smack-ci.sh b/util/run-gnu-tests-smack-ci.sh index 867a15dad4a..e37e29ca3fa 100755 --- a/util/run-gnu-tests-smack-ci.sh +++ b/util/run-gnu-tests-smack-ci.sh @@ -17,14 +17,15 @@ mkdir -p "$QEMU_DIR"/{rootfs/{bin,lib64,proc,sys,dev,tmp,etc,gnu},kernel} # Copy Ubuntu kernel (runner's kernel does not work) sudo apt-get update || : -sudo apt-get install -y --no-install-recommends linux-image-generic +sudo apt-get install -y linux-image-generic sudo install -Dvm644 "$(ls -1 /boot/vmlinuz-*-generic | head -n 1)" "$QEMU_DIR/kernel/vmlinuz" # Setup busybox -curl -L -o b.tar.gz https://dl-cdn.alpinelinux.org/alpine/latest-stable/main/x86_64/busybox-static-1.37.0-r30.apk -tar -xf b.tar.gz -install -Dvm755 bin/busybox.static "$QEMU_DIR/rootfs/bin/busybox" -(cd "$QEMU_DIR/rootfs/bin" && ./busybox --list | xargs -I{} ln -sf busybox {} 2>/dev/null) +BUSYBOX=/tmp/busybox +[ -f "$BUSYBOX" ] || curl -sL -o "$BUSYBOX" https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox +chmod +x "$BUSYBOX" +cp "$BUSYBOX" "$QEMU_DIR/rootfs/bin/" +(cd "$QEMU_DIR/rootfs/bin" && "$BUSYBOX" --list | xargs -I{} ln -sf busybox {} 2>/dev/null) # Copy required libraries for lib in ld-linux-x86-64.so.2 libc.so.6 libm.so.6 libgcc_s.so.1 libpthread.so.0 libdl.so.2 librt.so.1; do @@ -103,7 +104,7 @@ for TEST_PATH in $QEMU_TESTS; do # Hardlink utilities for SMACK/ROOTFS tests for U in $("$REPO_DIR/target/${PROFILE}/coreutils" --list); do - ln -f "$REPO_DIR/target/${PROFILE}/coreutils" "$WORK/bin/$U" + ln -vf "$REPO_DIR/target/${PROFILE}/coreutils" "$WORK/bin/$U" done # Set test script path and user