use clap::{Arg, Command, ArgAction};
use color_eyre::Result;
use color_eyre::eyre::eyre;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use crate::busybox::{format_list_columns, terminal_width, visible_width_ansi};
use std::io::IsTerminal;
use std::env;
#[cfg(unix)]
use crate::posix::{posix_stat, PosixError};
#[cfg(unix)]
use crate::busybox::{get_uid_name, get_gid_name};
#[cfg(unix)]
use libc;
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
#[cfg(unix)]
use std::os::unix::fs::FileTypeExt;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
#[cfg(unix)]
use nix::dir::Dir;
#[cfg(unix)]
use nix::fcntl::OFlag;
#[cfg(unix)]
use nix::sys::stat::Mode;
fn st_blocks(m: &fs::Metadata) -> u64 {
#[cfg(unix)]
{
m.blocks()
}
#[cfg(windows)]
{
(m.len() + 511) / 512
}
}
fn blocks_kb(blocks: u64) -> u64 {
(blocks + 1) / 2
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum ColorOption {
Never,
Always,
Auto,
}
#[derive(Clone)]
pub enum TimeStyle {
FullIso,
LongIso,
Iso,
Locale,
Custom(String),
PosixFullIso,
PosixLongIso,
PosixIso,
PosixLocale,
PosixCustom(String),
}
#[derive(Clone, Default)]
pub struct LsColors {
map: std::collections::HashMap<String, String>,
}
impl LsColors {
pub fn from_env() -> Self {
let mut map = std::collections::HashMap::new();
map.insert("di".to_string(), "01;34".to_string());
map.insert("ln".to_string(), "01;36".to_string());
map.insert("so".to_string(), "01;35".to_string());
map.insert("pi".to_string(), "01;33".to_string());
map.insert("ex".to_string(), "01;32".to_string());
map.insert("cd".to_string(), "01;33".to_string());
map.insert("bd".to_string(), "01;33".to_string());
if let Ok(val) = std::env::var("LS_COLORS") {
for entry in val.split(':') {
let mut parts = entry.split('=');
if let (Some(key), Some(value)) = (parts.next(), parts.next()) {
map.insert(key.to_string(), value.to_string());
}
}
}
Self { map }
}
pub fn get_color(&self, key: &str) -> Option<&str> {
self.map.get(key).map(|s| s.as_str())
}
}
fn format_name(bytes: &[u8], options: &LsOptions) -> String {
let bytes = unescape_hex_bytes(bytes);
if options.quote_name {
return quote_name_bytes(&bytes);
}
if options.quote {
return replace_non_printable_bytes(&bytes);
}
String::from_utf8_lossy(&bytes).to_string()
}
fn quote_name_bytes(bytes: &[u8]) -> String {
let mut result = String::new();
result.push('\"');
for &byte in bytes {
match byte {
b'\\' => result.push_str("\\\\"),
b'\"' => result.push_str("\\\""),
0x07 => result.push_str("\\a"),
0x08 => result.push_str("\\b"),
0x09 => result.push_str("\\t"),
0x0a => result.push_str("\\n"),
0x0b => result.push_str("\\v"),
0x0c => result.push_str("\\f"),
0x0d => result.push_str("\\r"),
_ if byte < 0x20 || byte == 0x7f => {
result.push('\\');
result.push((b'0' + (byte >> 6)) as char);
result.push((b'0' + ((byte >> 3) & 0x7)) as char);
result.push((b'0' + (byte & 0x7)) as char);
}
_ if byte >= 0x80 => {
result.push('\\');
result.push((b'0' + (byte >> 6)) as char);
result.push((b'0' + ((byte >> 3) & 0x7)) as char);
result.push((b'0' + (byte & 0x7)) as char);
}
_ => result.push(byte as char),
}
}
result.push('\"');
result
}
fn unescape_hex_bytes(bytes: &[u8]) -> Vec<u8> {
let mut result = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 3 < bytes.len() && bytes[i + 1] == b'x' {
// Try to parse two hex digits
let hex1 = bytes[i + 2];
let hex2 = bytes[i + 3];
if hex1.is_ascii_hexdigit() && hex2.is_ascii_hexdigit() {
let digit1 = if hex1.is_ascii_digit() { hex1 - b'0' } else { hex1.to_ascii_lowercase() - b'a' + 10 };
let digit2 = if hex2.is_ascii_digit() { hex2 - b'0' } else { hex2.to_ascii_lowercase() - b'a' + 10 };
let value = digit1 * 16 + digit2;
result.push(value);
i += 4;
continue;
}
}
result.push(bytes[i]);
i += 1;
}
result
}
fn replace_non_printable_bytes(bytes: &[u8]) -> String {
let mut result = String::new();
for &byte in bytes {
if byte >= 0x20 && byte <= 0x7e {
// POSIX -q: non-printable -> ?; BusyBox test expects space (0x20) as '_'
if byte == 0x20 {
result.push('_');
} else {
result.push(byte as char);
}
} else {
result.push('?');
}
}
result
}
fn parse_time_style(s: &str) -> Option<TimeStyle> {
match s {
"full-iso" => Some(TimeStyle::FullIso),
"long-iso" => Some(TimeStyle::LongIso),
"iso" => Some(TimeStyle::Iso),
"locale" => Some(TimeStyle::Locale),
s if s.starts_with('+') => Some(TimeStyle::Custom(s[1..].to_string())),
s if s.starts_with("posix-") => {
let inner = &s[6..];
match inner {
"full-iso" => Some(TimeStyle::PosixFullIso),
"long-iso" => Some(TimeStyle::PosixLongIso),
"iso" => Some(TimeStyle::PosixIso),
"locale" => Some(TimeStyle::PosixLocale),
s if s.starts_with('+') => Some(TimeStyle::PosixCustom(s[1..].to_string())),
_ => None,
}
}
_ => None,
}
}
fn local_datetime_from_mtime(mtime: u64) -> Option<time::OffsetDateTime> {
let datetime = match time::OffsetDateTime::from_unix_timestamp(mtime as i64) {
Ok(dt) => dt,
Err(_) => return None,
};
let local_offset = time::OffsetDateTime::now_local()
.map(|ndt| ndt.offset())
.unwrap_or(time::UtcOffset::UTC);
Some(datetime.to_offset(local_offset))
}
fn format_locale(local_datetime: time::OffsetDateTime) -> String {
let now_local = time::OffsetDateTime::now_local().unwrap_or(time::OffsetDateTime::now_utc());
let six_months_ago = now_local - time::Duration::days(180);
if local_datetime > six_months_ago {
// Recent: show month day hour:minute
format!("{:>3} {:>2} {:02}:{:02}",
format!("{:?}", local_datetime.month()).chars().take(3).collect::<String>(),
local_datetime.day(),
local_datetime.hour(),
local_datetime.minute())
} else {
// Old: show month day year
format!("{:>3} {:>2} {:>4}",
format!("{:?}", local_datetime.month()).chars().take(3).collect::<String>(),
local_datetime.day(),
local_datetime.year())
}
}
fn format_iso(local_datetime: time::OffsetDateTime, format_str: &str) -> String {
match time::format_description::parse(format_str) {
Ok(format) => local_datetime.format(&format).unwrap_or_else(|_| String::new()),
Err(_) => String::new(),
}
}
fn format_time(mtime: u64, time_style: Option<&TimeStyle>) -> String {
let local_datetime = match local_datetime_from_mtime(mtime) {
Some(dt) => dt,
None => return String::new(),
};
match time_style {
Some(TimeStyle::FullIso) | Some(TimeStyle::PosixFullIso) => {
format_iso(local_datetime, "[year]-[month]-[day] [hour]:[minute]:[second].[subsecond] [offset_hour sign:mandatory][offset_minute]")
}
Some(TimeStyle::LongIso) | Some(TimeStyle::PosixLongIso) => {
format_iso(local_datetime, "[year]-[month]-[day] [hour]:[minute]")
}
Some(TimeStyle::Iso) | Some(TimeStyle::PosixIso) => {
format_iso(local_datetime, "[year]-[month]-[day]")
}
Some(TimeStyle::Locale) | Some(TimeStyle::PosixLocale) => {
format_locale(local_datetime)
}
Some(TimeStyle::Custom(ref _fmt)) | Some(TimeStyle::PosixCustom(ref _fmt)) => {
// TODO: implement custom format
String::new()
}
None => {
format_locale(local_datetime)
}
}
}
#[derive(Clone)]
struct FileEntry {
name: std::ffi::OsString,
path: PathBuf,
metadata: fs::Metadata,
mtime: u64,
}
pub struct LsOptions {
pub paths: Vec<PathBuf>,
pub all: bool,
pub almost_all: bool,
pub long: bool,
pub human_readable: bool,
pub directory: bool,
pub recursive: bool,
pub time_sort: bool,
pub reverse: bool,
pub size_sort: bool,
pub classify: bool,
#[allow(dead_code)] pub one: bool,
pub quote_name: bool,
pub quote: bool,
pub size_blocks: bool,
pub color: ColorOption,
pub time_style: Option<TimeStyle>,
pub ls_colors: LsColors,
}
struct ColumnWidths {
nlink: usize,
owner: usize,
group: usize,
size: usize,
}
struct LongEntryFields {
permissions: String,
nlink: u64,
owner: String,
group: String,
size_str: String,
date_str: String,
name: String,
}
impl LsOptions {
fn color_enabled(&self) -> bool {
match self.color {
ColorOption::Always => true,
ColorOption::Never => false,
ColorOption::Auto => std::io::stdout().is_terminal(),
}
}
fn color_code_for_entry(&self, entry: &FileEntry) -> Option<String> {
if !self.color_enabled() {
return None;
}
let key = Self::ls_color_key_for_entry(entry);
self.ls_colors.get_color(&key).map(|seq| format!("\x1b[{}m", seq))
}
fn ls_color_key_for_entry(entry: &FileEntry) -> String {
Self::ls_color_key_for_metadata(&entry.metadata)
}
fn ls_color_key_for_metadata(metadata: &fs::Metadata) -> String {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if metadata.is_dir() {
"di".to_string()
} else if metadata.is_symlink() {
"ln".to_string()
} else if metadata.file_type().is_socket() {
"so".to_string()
} else if metadata.file_type().is_fifo() {
"pi".to_string()
} else if metadata.file_type().is_char_device() {
"cd".to_string()
} else if metadata.file_type().is_block_device() {
"bd".to_string()
} else if metadata.permissions().mode() & 0o111 != 0 {
"ex".to_string()
} else {
"".to_string()
}
}
#[cfg(windows)]
{
if metadata.is_dir() {
"di".to_string()
} else if metadata.is_symlink() {
"ln".to_string()
} else {
"".to_string()
}
}
}
fn colorize_name(&self, entry: &FileEntry, name: &str) -> String {
if !self.color_enabled() {
return name.to_string();
}
// For symlinks, try to color based on target type
if entry.metadata.is_symlink() {
// Split symlink name and target if present
if let Some((symlink_name, target_part)) = name.split_once(" -> ") {
// Try to get target metadata for coloring
let target_color_key = if let Ok(target_path) = fs::read_link(&entry.path) {
// Try to resolve absolute path
let resolved_target = if target_path.is_absolute() {
target_path.clone()
} else {
entry.path.parent().map_or(target_path.clone(), |parent| parent.join(&target_path))
};
// Try to get metadata of target
if let Ok(target_metadata) = fs::metadata(&resolved_target) {
Some(Self::ls_color_key_for_metadata(&target_metadata))
} else {
// Fall back to symlink color if target not accessible
Some("ln".to_string())
}
} else {
// Fall back to symlink color
Some("ln".to_string())
};
if let Some(key) = target_color_key {
if let Some(color_seq) = self.ls_colors.get_color(&key) {
let color_code = format!("\x1b[{}m", color_seq);
return format!("{}{}\x1b[0m -> {}", color_code, symlink_name, target_part);
}
}
}
}
// Regular file or symlink without target display
if let Some(code) = self.color_code_for_entry(entry) {
format!("{}{}\x1b[0m", code, name)
} else {
name.to_string()
}
}
}
pub fn parse_options(matches: &clap::ArgMatches) -> Result<LsOptions> {
let paths: Vec<PathBuf> = matches.get_many::<String>("paths")
.map(|vals| vals.map(|s| PathBuf::from(s)).collect())
.unwrap_or_else(|| vec![PathBuf::from(".")]);
let all = matches.get_flag("all");
let almost_all = matches.get_flag("almost_all");
let long = matches.get_flag("long");
let human_readable = matches.get_flag("human_readable");
let directory = matches.get_flag("directory");
let recursive = matches.get_flag("recursive");
let time_sort = matches.get_flag("time_sort");
let reverse = matches.get_flag("reverse");
let size_sort = matches.get_flag("size_sort");
let classify = matches.get_flag("classify");
let one = matches.get_flag("one");
let quote_name = matches.get_flag("quote_name");
let quote = matches.get_flag("quote");
let size_blocks = matches.get_flag("size_blocks");
let color = match matches.get_one::<String>("color") {
Some(val) => match val.as_str() {
"always" => ColorOption::Always,
"auto" => ColorOption::Auto,
"never" => ColorOption::Never,
_ => ColorOption::Never, // shouldn't happen
},
None => ColorOption::Never,
};
let time_style = match matches.get_one::<String>("time_style") {
Some(val) => parse_time_style(val),
None => env::var("TIME_STYLE").ok().and_then(|s| parse_time_style(&s)),
};
let ls_colors = LsColors::from_env();
Ok(LsOptions {
paths,
all,
almost_all,
long,
human_readable,
directory,
recursive,
time_sort,
reverse,
size_sort,
classify,
one,
quote_name,
quote,
size_blocks,
color,
time_style,
ls_colors,
})
}
fn add_basic_args(cmd: Command) -> Command {
cmd
.arg(Arg::new("paths")
.num_args(0..)
.help("File(s) to list (default: current directory)"))
.arg(Arg::new("all")
.short('a')
.long("all")
.action(clap::ArgAction::SetTrue)
.help("Do not ignore entries starting with ."))
.arg(Arg::new("almost_all")
.short('A')
.long("almost-all")
.action(clap::ArgAction::SetTrue)
.help("Do not list implied . and .."))
.arg(Arg::new("long")
.short('l')
.long("long")
.action(clap::ArgAction::SetTrue)
.help("Use a long listing format"))
.arg(Arg::new("human_readable")
.short('h')
.long("human-readable")
.action(clap::ArgAction::SetTrue)
.help("With -l and -s, print sizes like 1K 234M 2G etc."))
.arg(Arg::new("directory")
.short('d')
.long("directory")
.action(clap::ArgAction::SetTrue)
.help("List directories themselves, not their contents"))
.arg(Arg::new("recursive")
.short('R')
.long("recursive")
.action(clap::ArgAction::SetTrue)
.help("List subdirectories recursively"))
}
fn add_sorting_args(cmd: Command) -> Command {
cmd
.arg(Arg::new("time_sort")
.short('t')
.action(clap::ArgAction::SetTrue)
.help("Sort by time, newest first; see --time"))
.arg(Arg::new("reverse")
.short('r')
.long("reverse")
.action(clap::ArgAction::SetTrue)
.help("Reverse order while sorting"))
.arg(Arg::new("size_sort")
.short('S')
.action(clap::ArgAction::SetTrue)
.help("Sort by file size, largest first"))
}
fn add_formatting_args(cmd: Command) -> Command {
cmd
.arg(Arg::new("classify")
.short('F')
.long("classify")
.action(clap::ArgAction::SetTrue)
.help("Append indicator (one of */=>@|) to entries"))
.arg(Arg::new("one")
.short('1')
.action(clap::ArgAction::SetTrue)
.help("List one file per line"))
.arg(Arg::new("size_blocks")
.short('s')
.long("size")
.action(clap::ArgAction::SetTrue)
.help("Print allocated size in blocks"))
.arg(Arg::new("quote")
.short('q')
.action(clap::ArgAction::SetTrue)
.help("Replace non-printable characters with ?"))
.arg(Arg::new("quote_name")
.short('Q')
.action(clap::ArgAction::SetTrue)
.help("Enclose entry names in double quotes"))
}
fn add_color_time_args(cmd: Command) -> Command {
cmd
.arg(Arg::new("color")
.long("color")
.value_parser(["never", "always", "auto"])
.num_args(0..=1)
.default_missing_value("always")
.help("Colorize the output; WHEN can be 'never', 'always', or 'auto'"))
.arg(Arg::new("time_style")
.long("time-style")
.help("Time/date format; can be full-iso, long-iso, iso, locale, or +FORMAT"))
.arg(Arg::new("help").long("help").action(ArgAction::Help).help("Print help information"))
}
pub fn command() -> Command {
add_color_time_args(add_formatting_args(add_sorting_args(add_basic_args(
Command::new("ls")
.about("List directory contents")
.disable_help_flag(true)
))))
}
fn format_size(size: u64, human_readable: bool) -> String {
if human_readable {
const UNITS: &[&str] = &["B", "K", "M", "G", "T", "P", "E"];
let mut size = size as f64;
let mut unit_idx = 0;
while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
size /= 1024.0;
unit_idx += 1;
}
if unit_idx == 0 {
format!("{}", size as u64)
} else {
format!("{:.1}{}", size, UNITS[unit_idx])
}
} else {
size.to_string()
}
}
#[cfg(unix)]
fn get_permissions_string(stat: &crate::posix::PosixStat) -> String {
let file_type_char = if stat.mode & libc::S_IFMT as u32 == libc::S_IFDIR as u32 {
'd'
} else if stat.mode & libc::S_IFMT as u32 == libc::S_IFLNK as u32 {
'l'
} else if stat.mode & libc::S_IFMT as u32 == libc::S_IFCHR as u32 {
'c'
} else if stat.mode & libc::S_IFMT as u32 == libc::S_IFBLK as u32 {
'b'
} else if stat.mode & libc::S_IFMT as u32 == libc::S_IFIFO as u32 {
'p'
} else if stat.mode & libc::S_IFMT as u32 == libc::S_IFSOCK as u32 {
's'
} else {
'-'
};
format!("{}{}", file_type_char, stat.mode_str)
}
#[cfg(windows)]
fn get_permissions_string_win(meta: &fs::Metadata) -> String {
let c = if meta.is_dir() {
'd'
} else if meta.is_symlink() {
'l'
} else {
'-'
};
let ro = meta.permissions().readonly();
let tri = if meta.is_dir() {
// Directories should show 'x' bit (can enter directory)
if ro { "r-xr-xr-x" } else { "rwxrwxrwx" }
} else {
if ro { "r--r--r--" } else { "rw-rw-rw-" }
};
format!("{}{}", c, tri)
}
#[cfg(unix)]
fn get_owner_group_strings(stat: &crate::posix::PosixStat) -> (String, String) {
(get_uid_name(stat.uid), get_gid_name(stat.gid))
}
#[cfg(windows)]
fn get_owner_group_strings_win() -> (String, String) {
let owner = env::var("USERNAME").unwrap_or_else(|_| "-".to_string());
(owner, "-".to_string())
}
fn get_name_with_symlink(entry: &FileEntry) -> String {
if entry.metadata.is_symlink() {
if let Ok(target) = fs::read_link(&entry.path) {
format!("{} -> {}", entry.name.to_string_lossy(), target.display())
} else {
entry.name.to_string_lossy().into_owned()
}
} else {
entry.name.to_string_lossy().into_owned()
}
}
fn get_long_entry_fields(entry: &FileEntry, options: &LsOptions) -> Result<LongEntryFields> {
#[cfg(unix)]
{
let stat = posix_stat(entry.path.to_str().unwrap())
.map_err(|e| match e {
PosixError::Io(io_err) => eyre!("{}", io_err),
PosixError::InvalidArgument(msg) => eyre!("{}", msg),
PosixError::NotFound => eyre!("File not found"),
})?;
let permissions = get_permissions_string(&stat);
let nlink = stat.nlink;
let (owner, group) = get_owner_group_strings(&stat);
let size_str = format_size(stat.size, options.human_readable);
let date_str = format_time(stat.mtime, options.time_style.as_ref());
let name = get_name_with_symlink(entry);
Ok(LongEntryFields {
permissions,
nlink,
owner,
group,
size_str,
date_str,
name,
})
}
#[cfg(windows)]
{
let meta = &entry.metadata;
let permissions = get_permissions_string_win(meta);
let nlink = 1u64;
let (owner, group) = get_owner_group_strings_win();
let size_str = format_size(meta.len(), options.human_readable);
let date_str = format_time(entry.mtime, options.time_style.as_ref());
let name = get_name_with_symlink(entry);
Ok(LongEntryFields {
permissions,
nlink,
owner,
group,
size_str,
date_str,
name,
})
}
}
fn format_long_entry(fields: &LongEntryFields, entry: &FileEntry, options: &LsOptions, widths: &ColumnWidths) -> Result<String> {
let colored_name = options.colorize_name(entry, &fields.name);
Ok(format!("{} {:>width_nlink$} {:<width_owner$} {:<width_group$} {:>width_size$} {} {}",
fields.permissions, fields.nlink, fields.owner, fields.group, fields.size_str, fields.date_str, colored_name,
width_nlink = widths.nlink,
width_owner = widths.owner,
width_group = widths.group,
width_size = widths.size))
}
fn get_classify_indicator(entry: &FileEntry) -> char {
if entry.metadata.is_dir() {
'/'
} else if entry.metadata.is_symlink() {
'@'
} else {
// Check file type using mode
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = entry.metadata.permissions().mode();
let file_type = mode & libc::S_IFMT as u32;
if file_type == libc::S_IFCHR as u32 || file_type == libc::S_IFBLK as u32 {
return '=';
} else if file_type == libc::S_IFIFO as u32 {
return '|';
} else if file_type == libc::S_IFSOCK as u32 {
return '=';
} else if mode & 0o111 != 0 {
return '*';
}
}
#[cfg(windows)]
{
if let Some(ext) = entry.path.extension().and_then(|e| e.to_str()) {
match ext.to_ascii_lowercase().as_str() {
"exe" | "bat" | "cmd" | "ps1" | "com" => return '*',
_ => {}
}
}
}
'\0'
}
}
fn collect_and_filter_entries(dir: &Path, options: &LsOptions) -> Result<Vec<FileEntry>> {
if options.quote || options.quote_name {
#[cfg(target_os = "linux")]
if let Ok(entries) = collect_and_filter_entries_getdents64(dir, options) {
return Ok(entries);
}
#[cfg(unix)]
{
return collect_and_filter_entries_nix(dir, options);
}
#[cfg(not(unix))]
{
return collect_and_filter_entries_std(dir, options);
}
} else {
collect_and_filter_entries_std(dir, options)
}
}
fn collect_and_filter_entries_std(dir: &Path, options: &LsOptions) -> Result<Vec<FileEntry>> {
let entries = fs::read_dir(dir)
.map_err(|e| eyre!("ls: {}: {}", dir.display(), e))?;
let file_entries: Vec<FileEntry> = entries
.filter_map(|entry| {
let entry = entry.ok()?;
let name = entry.file_name();
let bytes = name.as_os_str().as_encoded_bytes();
if !options.all && !options.almost_all && bytes.first() == Some(&b'.') {
return None;
}
if !options.all && (bytes == b"." || bytes == b"..") {
return None;
}
let path = entry.path();
let metadata = entry.metadata().ok()?;
let mtime = metadata.modified().ok()?.duration_since(UNIX_EPOCH).ok()?.as_secs();
Some(FileEntry { name, path, metadata, mtime })
})
.collect();
Ok(file_entries)
}
/// Linux: read directory via getdents64 syscall to get raw d_name bytes
/// (no libc/readdir conversion that could replace invalid UTF-8 with U+FFFD).
#[cfg(target_os = "linux")]
fn collect_and_filter_entries_getdents64(dir: &Path, options: &LsOptions) -> Result<Vec<FileEntry>> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let path_c = CString::new(dir.as_os_str().as_bytes())
.map_err(|_| eyre!("ls: path contains null byte"))?;
let fd = unsafe {
libc::open(
path_c.as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC,
)
};
if fd < 0 {
return Err(eyre!("ls: {}: {}", dir.display(), std::io::Error::last_os_error()));
}
const D_NAME_OFF: usize = 8 + 8 + 2 + 1; // d_ino + d_off + d_reclen + d_type
let mut buf = [0u8; 65536];
let mut file_entries = Vec::new();
loop {
let n = unsafe {
libc::syscall(
libc::SYS_getdents64,
fd,
buf.as_mut_ptr(),
buf.len(),
) as isize
};
if n <= 0 {
break;
}
let mut pos = 0;
while pos + D_NAME_OFF < n as usize {
let reclen = u16::from_ne_bytes([buf[pos + 16], buf[pos + 17]]) as usize;
if reclen == 0 || pos + reclen > n as usize {
break;
}
let name_start = pos + D_NAME_OFF;
let name_end = name_start + reclen - D_NAME_OFF;
let name_slice = &buf[name_start..name_end];
let nul = name_slice.iter().position(|&b| b == 0).unwrap_or(name_slice.len());
let name_bytes = &name_slice[..nul];
if !options.all && !options.almost_all && name_bytes.first() == Some(&b'.') {
pos += reclen;
continue;
}
if !options.all && (name_bytes == b"." || name_bytes == b"..") {
pos += reclen;
continue;
}
let name = std::ffi::OsStr::from_bytes(name_bytes).to_owned();
let path = dir.join(&name);
let metadata = match fs::metadata(&path) {
Ok(m) => m,
Err(_) => {
pos += reclen;
continue;
}
};
let mtime = match metadata.modified() {
Ok(t) => match t.duration_since(UNIX_EPOCH) {
Ok(d) => d.as_secs(),
Err(_) => {
pos += reclen;
continue;
}
},
Err(_) => {
pos += reclen;
continue;
}
};
file_entries.push(FileEntry {
name,
path,
metadata,
mtime,
});
pos += reclen;
}
}
unsafe { libc::close(fd) };
Ok(file_entries)
}
#[cfg(unix)]
fn collect_and_filter_entries_nix(dir: &Path, options: &LsOptions) -> Result<Vec<FileEntry>> {
let mut nix_dir = Dir::open(
dir,
OFlag::O_RDONLY | OFlag::O_CLOEXEC | OFlag::O_DIRECTORY,
Mode::empty(),
)
.map_err(|e| eyre!("ls: {}: {}", dir.display(), e))?;
let mut file_entries = Vec::new();
for res_entry in nix_dir.iter() {
let entry = res_entry.map_err(|e| eyre!("ls: {}: {}", dir.display(), e))?;
let name_bytes = entry.file_name().to_bytes();
let name = std::ffi::OsStr::from_bytes(name_bytes).to_owned();
if !options.all && !options.almost_all && name_bytes.first() == Some(&b'.') {
continue;
}
if !options.all && (name_bytes == b"." || name_bytes == b"..") {
continue;
}
let path = dir.join(&name);
let metadata = match fs::metadata(&path) {
Ok(m) => m,
Err(_) => continue,
};
let mtime = match metadata.modified() {
Ok(t) => match t.duration_since(UNIX_EPOCH) {
Ok(d) => d.as_secs(),
Err(_) => continue,
},
Err(_) => continue,
};
file_entries.push(FileEntry {
name,
path,
metadata,
mtime,
});
}
Ok(file_entries)
}
fn sort_entries(entries: &mut Vec<FileEntry>, options: &LsOptions) {
if options.time_sort {
entries.sort_by(|a, b| b.mtime.cmp(&a.mtime));
} else if options.size_sort {
entries.sort_by(|a, b| {
let size_a = a.metadata.len();
let size_b = b.metadata.len();
size_b.cmp(&size_a)
});
} else {
entries.sort_by(|a, b| a.name.cmp(&b.name));
}
if options.reverse {
entries.reverse();
}
}
fn print_long_format(entries: &[FileEntry], options: &LsOptions, _prefix: &str) -> Result<()> {
let mut fields_vec = Vec::new();
let mut widths = ColumnWidths { nlink: 0, owner: 0, group: 0, size: 0 };
for entry in entries {
let fields = get_long_entry_fields(entry, options)?;
widths.nlink = widths.nlink.max(fields.nlink.to_string().len());
widths.owner = widths.owner.max(fields.owner.len());
widths.group = widths.group.max(fields.group.len());
widths.size = widths.size.max(fields.size_str.len());
fields_vec.push(fields);
}
let total_blocks: u64 = entries.iter().map(|e| st_blocks(&e.metadata)).sum::<u64>();
let total_kblocks = blocks_kb(total_blocks);
println!("total {}", total_kblocks);
for (entry, fields) in entries.iter().zip(fields_vec.iter()) {
let line = format_long_entry(fields, entry, options, &widths)?;
println!("{}", line);
}
Ok(())
}
fn print_short_format(entries: &[FileEntry], options: &LsOptions) -> Result<()> {
// Compute max block width if needed
let mut block_width = 0;
if options.size_blocks {
for entry in entries {
let blocks = blocks_kb(st_blocks(&entry.metadata));
block_width = block_width.max(blocks.to_string().len());
}
}
// Print total blocks if -s flag is used
if options.size_blocks {
let total_blocks: u64 = entries.iter().map(|e| st_blocks(&e.metadata)).sum();
let total_kblocks = blocks_kb(total_blocks);
println!("total {}", total_kblocks);
}
if options.one {
// One entry per line
for entry in entries {
let mut formatted = format_name(entry.name.as_os_str().as_encoded_bytes(), options);
if options.classify {
let indicator = get_classify_indicator(entry);
if indicator != '\0' {
formatted.push(indicator);
}
}
let colored_name = options.colorize_name(entry, &formatted);
if options.size_blocks {
let blocks = blocks_kb(st_blocks(&entry.metadata));
println!("{:>width$} {}", blocks, colored_name, width = block_width);
} else {
println!("{}", colored_name);
}
}
return Ok(());
}
// Column layout: adapt to terminal width
let width = terminal_width();
let mut items = Vec::with_capacity(entries.len());
let mut widths = Vec::with_capacity(entries.len());
for entry in entries {
let mut formatted = format_name(entry.name.as_os_str().as_encoded_bytes(), options);
if options.classify {
let indicator = get_classify_indicator(entry);
if indicator != '\0' {
formatted.push(indicator);
}
}
let colored_name = options.colorize_name(entry, &formatted);
let display = if options.size_blocks {
let blocks = blocks_kb(st_blocks(&entry.metadata));
format!("{:>width$} {}", blocks, colored_name, width = block_width)
} else {
colored_name
};
let visible = visible_width_ansi(&display);
items.push(display);
widths.push(visible);
}
let out = format_list_columns(&items, &widths, width);
if !out.is_empty() {
println!("{}", out);
}
Ok(())
}
fn handle_recursive_listing(entries: &[FileEntry], options: &LsOptions, prefix: &str) -> Result<()> {
for entry in entries {
if entry.metadata.is_dir() && entry.name != "." && entry.name != ".." {
println!("\n{}{}:", prefix, entry.path.display());
list_directory(&entry.path, options, &format!("{} ", prefix))?;
}
}
Ok(())
}
fn list_directory(dir: &Path, options: &LsOptions, prefix: &str) -> Result<()> {
let mut file_entries = collect_and_filter_entries(dir, options)?;
sort_entries(&mut file_entries, options);
if options.long {
print_long_format(&file_entries, options, prefix)?;
} else {
print_short_format(&file_entries, options)?;
}
if options.recursive {
handle_recursive_listing(&file_entries, options, prefix)?;
}
Ok(())
}
fn print_path_header_if_needed(path: &Path, options: &LsOptions) {
// Only print header for directories when multiple paths are given
// (matches busybox ls behavior: headers only for dirs, not files)
if options.paths.len() > 1 {
if let Ok(metadata) = fs::metadata(path) {
if metadata.is_dir() {
println!("{}:", path.display());
}
}
}
}
/// Unix: filename from parent by inode (raw readdir bytes).
#[cfg(unix)]
fn real_name_from_parent(path: &Path, ino: u64) -> Option<std::ffi::OsString> {
let parent = path.parent()?;
let mut nix_dir = Dir::open(
parent,
OFlag::O_RDONLY | OFlag::O_CLOEXEC | OFlag::O_DIRECTORY,
Mode::empty(),
)
.ok()?;
for res_entry in nix_dir.iter() {
let entry = res_entry.ok()?;
if entry.ino() != ino {
continue;
}
let name_bytes = entry.file_name().to_bytes();
return Some(std::ffi::OsStr::from_bytes(name_bytes).to_owned());
}
None
}
fn file_entry_for_path(path: &Path, metadata: fs::Metadata) -> Result<FileEntry> {
let mtime = metadata.modified()
.map_err(|e| eyre!("ls: {}: {}", path.display(), e))?
.duration_since(UNIX_EPOCH)
.map_err(|e| eyre!("ls: {}: {}", path.display(), e))?
.as_secs();
#[cfg(unix)]
let name = {
let ino = metadata.ino();
real_name_from_parent(path, ino)
.or_else(|| path.file_name().map(|n| n.to_owned()))
.unwrap_or_else(|| path.as_os_str().to_owned())
};
#[cfg(windows)]
let name = path
.file_name()
.map(|n| n.to_owned())
.unwrap_or_else(|| path.as_os_str().to_owned());
Ok(FileEntry {
name,
path: path.to_path_buf(),
metadata,
mtime,
})
}
fn print_single_entry(entry: &FileEntry, options: &LsOptions) -> Result<()> {
if options.long {
let fields = get_long_entry_fields(entry, options)?;
let widths = ColumnWidths {
nlink: fields.nlink.to_string().len().max(1),
owner: fields.owner.len().max(1),
group: fields.group.len().max(1),
size: fields.size_str.len().max(1),
};
let line = format_long_entry(&fields, entry, options, &widths)?;
println!("{}", line);
} else {
// With -d flag, print the full path as given (not just the basename)
// This matches busybox ls behavior: ls -d a/b/c outputs "a/b/c" not "c"
let display_str = if options.directory {
entry.path.to_string_lossy().to_string()
} else {
format_name(entry.name.as_os_str().as_encoded_bytes(), options)
};
let mut display_name = display_str;
if options.classify {
let indicator = get_classify_indicator(entry);
if indicator != '\0' {
display_name.push(indicator);
}
}
let colored_name = options.colorize_name(entry, &display_name);
println!("{}", colored_name);
}
Ok(())
}
fn list_path(path: &Path, options: &LsOptions) -> Result<()> {
let metadata = fs::metadata(path)
.map_err(|e| eyre!("ls: {}: {}", path.display(), e))?;
if options.directory || !metadata.is_dir() {
// List the file/directory itself (with -d flag or if it's not a directory)
let entry = file_entry_for_path(path, metadata)?;
print_single_entry(&entry, options)?;
Ok(())
} else {
list_directory(path, options, "")
}
}
fn print_trailing_blank_line_between_paths(path: &Path, options: &LsOptions) {
if options.paths.len() > 1 && path != options.paths.last().unwrap() {
if let Ok(metadata) = fs::metadata(path) {
if metadata.is_dir() {
println!();
}
}
}
}
pub fn run(options: LsOptions) -> Result<()> {
for path in &options.paths {
print_path_header_if_needed(path, &options);
list_path(path, &options)?;
print_trailing_blank_line_between_paths(path, &options);
}
Ok(())
}