Skip to main content

f00_core/
entry.rs

1use std::collections::HashMap;
2use std::fs::{FileType, Metadata};
3use std::path::{Path, PathBuf};
4use std::sync::Mutex;
5use std::time::SystemTime;
6
7use chrono::{DateTime, Local};
8
9use crate::error::{Error, Result};
10
11/// Which expensive metadata fields to populate when building an [`Entry`].
12#[derive(Debug, Clone, Copy, Default)]
13pub struct MetaFill {
14    /// Resolve uid/gid via NSS (getpwuid/getgrgid). Cached process-wide.
15    pub resolve_names: bool,
16    /// Read `security.selinux` xattr (Linux).
17    pub read_context: bool,
18}
19
20impl MetaFill {
21    /// Full long-format fill (names + optional SELinux when requested separately).
22    pub fn rich(read_context: bool) -> Self {
23        Self {
24            resolve_names: true,
25            read_context,
26        }
27    }
28
29    /// Minimal fill for short listings / machine formats that only need path+size+kind.
30    pub fn cheap() -> Self {
31        Self::default()
32    }
33}
34
35/// Process-wide caches for uid/gid → name (avoids repeated NSS in long mode).
36fn owner_cache() -> &'static Mutex<HashMap<u32, String>> {
37    use std::sync::OnceLock;
38    static CACHE: OnceLock<Mutex<HashMap<u32, String>>> = OnceLock::new();
39    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
40}
41
42fn group_cache() -> &'static Mutex<HashMap<u32, String>> {
43    use std::sync::OnceLock;
44    static CACHE: OnceLock<Mutex<HashMap<u32, String>>> = OnceLock::new();
45    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
46}
47
48/// High-level file kind used for display and sorting.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50pub enum EntryKind {
51    File,
52    Directory,
53    Symlink,
54    Other,
55}
56
57impl EntryKind {
58    pub fn from_file_type(ft: FileType) -> Self {
59        if ft.is_dir() {
60            Self::Directory
61        } else if ft.is_symlink() {
62            Self::Symlink
63        } else if ft.is_file() {
64            Self::File
65        } else {
66            Self::Other
67        }
68    }
69
70    pub fn as_str(self) -> &'static str {
71        match self {
72            Self::File => "file",
73            Self::Directory => "directory",
74            Self::Symlink => "symlink",
75            Self::Other => "other",
76        }
77    }
78}
79
80/// Optional git status annotation (filled by f00-git when enabled).
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
82pub enum GitStatus {
83    #[default]
84    Clean,
85    Modified,
86    Added,
87    Deleted,
88    Renamed,
89    Untracked,
90    Ignored,
91    Conflicted,
92    Unknown,
93}
94
95impl GitStatus {
96    pub fn as_char(self) -> Option<char> {
97        match self {
98            Self::Clean => None,
99            Self::Modified => Some('M'),
100            Self::Added => Some('A'),
101            Self::Deleted => Some('D'),
102            Self::Renamed => Some('R'),
103            Self::Untracked => Some('?'),
104            Self::Ignored => Some('!'),
105            Self::Conflicted => Some('U'),
106            Self::Unknown => Some(' '),
107        }
108    }
109
110    pub fn as_str(self) -> &'static str {
111        match self {
112            Self::Clean => "clean",
113            Self::Modified => "modified",
114            Self::Added => "added",
115            Self::Deleted => "deleted",
116            Self::Renamed => "renamed",
117            Self::Untracked => "untracked",
118            Self::Ignored => "ignored",
119            Self::Conflicted => "conflicted",
120            Self::Unknown => "unknown",
121        }
122    }
123}
124
125/// Which timestamp is primary for display / sort (`ls --time`).
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
127pub enum TimeField {
128    #[default]
129    Modified,
130    Accessed,
131    Changed,
132    Birth,
133}
134
135/// A single filesystem entry ready for formatting.
136#[derive(Debug, Clone)]
137pub struct Entry {
138    pub path: PathBuf,
139    pub name: String,
140    pub kind: EntryKind,
141    pub size: u64,
142    pub modified: Option<SystemTime>,
143    pub created: Option<SystemTime>,
144    pub accessed: Option<SystemTime>,
145    /// Status-change time (`st_ctime`) when available.
146    pub changed: Option<SystemTime>,
147    /// Permission mode bits (unix) or 0 on platforms without them.
148    pub mode: u32,
149    pub readonly: bool,
150    pub symlink_target: Option<PathBuf>,
151    pub depth: usize,
152    pub git_status: GitStatus,
153    /// True when this entry is a directory listing header (for recursive mode).
154    pub is_dir_header: bool,
155    /// Hard link count (`st_nlink`).
156    pub nlink: u64,
157    pub uid: u32,
158    pub gid: u32,
159    pub inode: u64,
160    /// Allocated blocks in 512-byte units (GNU `ls -s` style) when known.
161    pub blocks: u64,
162    /// Device ID containing the file (`st_dev` / `stx_dev`).
163    pub dev: u64,
164    /// Device ID (if special file) (`st_rdev` / `stx_rdev`).
165    pub rdev: u64,
166    /// Preferred I/O block size (`st_blksize` / `stx_blksize`) when known.
167    pub blksize: u64,
168    /// Owner name (or numeric string).
169    pub owner: String,
170    /// Group name (or numeric string).
171    pub group: String,
172    /// Author (GNU `--author`); typically same as owner on Linux.
173    pub author: String,
174    /// SELinux security context (`-Z`); empty if unavailable.
175    pub context: String,
176}
177
178impl Entry {
179    pub fn from_path(path: impl AsRef<Path>, depth: usize) -> Result<Self> {
180        Self::from_path_with(path, depth, MetaFill::default())
181    }
182
183    pub fn from_path_with(path: impl AsRef<Path>, depth: usize, fill: MetaFill) -> Result<Self> {
184        let path = path.as_ref();
185        let meta = std::fs::symlink_metadata(path).map_err(|source| Error::Metadata {
186            path: path.to_path_buf(),
187            source,
188        })?;
189        Self::from_path_and_meta_with(path, &meta, depth, fill)
190    }
191
192    /// Like [`from_path`] but follow the final symlink target for metadata (`-L`).
193    pub fn from_path_follow(path: impl AsRef<Path>, depth: usize) -> Result<Self> {
194        Self::from_path_follow_with(path, depth, MetaFill::default())
195    }
196
197    pub fn from_path_follow_with(
198        path: impl AsRef<Path>,
199        depth: usize,
200        fill: MetaFill,
201    ) -> Result<Self> {
202        let path = path.as_ref();
203        let meta = std::fs::metadata(path).map_err(|source| Error::Metadata {
204            path: path.to_path_buf(),
205            source,
206        })?;
207        let mut entry = Self::from_path_and_meta_with(path, &meta, depth, fill)?;
208        // Keep symlink name but drop "-> target" when showing dereference.
209        if entry.kind == EntryKind::Symlink {
210            entry.kind = EntryKind::from_file_type(meta.file_type());
211            entry.symlink_target = None;
212        }
213        Ok(entry)
214    }
215
216    pub fn from_path_and_meta(path: &Path, meta: &Metadata, depth: usize) -> Result<Self> {
217        Self::from_path_and_meta_with(path, meta, depth, MetaFill::default())
218    }
219
220    pub fn from_path_and_meta_with(
221        path: &Path,
222        meta: &Metadata,
223        depth: usize,
224        fill: MetaFill,
225    ) -> Result<Self> {
226        let file_type = meta.file_type();
227        let kind = EntryKind::from_file_type(file_type);
228        let name = path
229            .file_name()
230            .map(|s| s.to_string_lossy().into_owned())
231            .unwrap_or_else(|| path.to_string_lossy().into_owned());
232
233        let symlink_target = if file_type.is_symlink() {
234            std::fs::read_link(path).ok()
235        } else {
236            None
237        };
238
239        let mode = file_mode(meta);
240        let (nlink, uid, gid, inode, blocks, dev, rdev, blksize) = meta_ids(meta);
241        let (owner, group) = if fill.resolve_names {
242            (resolve_owner_cached(uid), resolve_group_cached(gid))
243        } else {
244            (String::new(), String::new())
245        };
246        let changed = ctime_of(meta);
247        let context = if fill.read_context {
248            read_selinux_context(path)
249        } else {
250            String::new()
251        };
252
253        Ok(Self {
254            path: path.to_path_buf(),
255            name,
256            kind,
257            size: meta.len(),
258            modified: meta.modified().ok(),
259            created: meta.created().ok(),
260            accessed: meta.accessed().ok(),
261            changed,
262            mode,
263            readonly: meta.permissions().readonly(),
264            symlink_target,
265            depth,
266            git_status: GitStatus::Clean,
267            is_dir_header: false,
268            nlink,
269            uid,
270            gid,
271            inode,
272            blocks,
273            dev,
274            rdev,
275            blksize,
276            author: owner.clone(),
277            owner,
278            group,
279            context,
280        })
281    }
282
283    /// Create a synthetic directory header for recursive listings.
284    pub fn dir_header(path: impl AsRef<Path>, depth: usize) -> Self {
285        let path = path.as_ref().to_path_buf();
286        let name = path.to_string_lossy().into_owned();
287        Self {
288            path,
289            name,
290            kind: EntryKind::Directory,
291            size: 0,
292            modified: None,
293            created: None,
294            accessed: None,
295            changed: None,
296            mode: 0,
297            readonly: false,
298            symlink_target: None,
299            depth,
300            git_status: GitStatus::Clean,
301            is_dir_header: true,
302            nlink: 0,
303            uid: 0,
304            gid: 0,
305            inode: 0,
306            blocks: 0,
307            dev: 0,
308            rdev: 0,
309            blksize: 0,
310            owner: String::new(),
311            group: String::new(),
312            author: String::new(),
313            context: String::new(),
314        }
315    }
316
317    pub fn is_hidden(&self) -> bool {
318        self.name.starts_with('.')
319    }
320
321    pub fn is_dir(&self) -> bool {
322        self.kind == EntryKind::Directory
323    }
324
325    /// Fine-grained type string for machine formats (`file`, `fifo`, `socket`, …).
326    pub fn type_detail(&self) -> &'static str {
327        match self.kind {
328            EntryKind::File => "file",
329            EntryKind::Directory => "directory",
330            EntryKind::Symlink => "symlink",
331            EntryKind::Other => {
332                #[cfg(unix)]
333                {
334                    match self.mode & 0o170000 {
335                        0o010000 => "fifo",
336                        0o140000 => "socket",
337                        0o060000 => "block_device",
338                        0o020000 => "character_device",
339                        _ => "other",
340                    }
341                }
342                #[cfg(not(unix))]
343                {
344                    "other"
345                }
346            }
347        }
348    }
349
350    pub fn is_executable(&self) -> bool {
351        #[cfg(unix)]
352        {
353            self.mode & 0o111 != 0
354        }
355        #[cfg(not(unix))]
356        {
357            false
358        }
359    }
360
361    /// `(major, minor)` for `st_dev`.
362    pub fn dev_major_minor(&self) -> (u32, u32) {
363        dev_major_minor(self.dev)
364    }
365
366    /// `(major, minor)` for `st_rdev` (special files).
367    pub fn rdev_major_minor(&self) -> (u32, u32) {
368        dev_major_minor(self.rdev)
369    }
370
371    /// Extended attribute names (`listxattr`); empty when unsupported or none.
372    pub fn xattr_names(&self) -> Vec<String> {
373        list_xattr_names(&self.path)
374    }
375
376    /// Permission bits only (no file-type nibble), as octal string without leading type.
377    pub fn mode_perms_octal(&self) -> String {
378        format!("{:o}", self.mode & 0o7777)
379    }
380
381    /// Full mode including type bits when available.
382    pub fn mode_full_octal(&self) -> String {
383        format!("{:o}", self.mode)
384    }
385
386    pub fn modified_datetime(&self) -> Option<DateTime<Local>> {
387        self.modified.map(DateTime::<Local>::from)
388    }
389
390    pub fn accessed_datetime(&self) -> Option<DateTime<Local>> {
391        self.accessed.map(DateTime::<Local>::from)
392    }
393
394    pub fn created_datetime(&self) -> Option<DateTime<Local>> {
395        self.created.map(DateTime::<Local>::from)
396    }
397
398    pub fn changed_datetime(&self) -> Option<DateTime<Local>> {
399        self.changed.map(DateTime::<Local>::from)
400    }
401
402    pub fn time_for(&self, field: TimeField) -> Option<SystemTime> {
403        match field {
404            TimeField::Modified => self.modified,
405            TimeField::Accessed => self.accessed,
406            TimeField::Changed => self.changed.or(self.modified),
407            TimeField::Birth => self.created.or(self.modified),
408        }
409    }
410
411    pub fn datetime_for(&self, field: TimeField) -> Option<DateTime<Local>> {
412        self.time_for(field).map(DateTime::<Local>::from)
413    }
414
415    pub fn extension(&self) -> Option<&str> {
416        Path::new(&self.name).extension().and_then(|e| e.to_str())
417    }
418
419    /// Owner string for display (`-n` forces numeric).
420    pub fn owner_display(&self, numeric: bool) -> String {
421        if numeric {
422            self.uid.to_string()
423        } else {
424            self.owner.clone()
425        }
426    }
427
428    pub fn group_display(&self, numeric: bool) -> String {
429        if numeric {
430            self.gid.to_string()
431        } else {
432            self.group.clone()
433        }
434    }
435
436    pub fn author_display(&self, numeric: bool) -> String {
437        if numeric {
438            self.uid.to_string()
439        } else if !self.author.is_empty() {
440            self.author.clone()
441        } else {
442            self.owner_display(numeric)
443        }
444    }
445
446    /// Fill owner/group/author (and optional SELinux context) after a cheap stat.
447    ///
448    /// Used so batch `statx` / io_uring can collect metadata first, then resolve
449    /// expensive fields without re-statting.
450    pub fn fill_expensive(&mut self, fill: MetaFill) {
451        if fill.resolve_names {
452            self.owner = resolve_owner_cached(self.uid);
453            self.group = resolve_group_cached(self.gid);
454            self.author = self.owner.clone();
455        }
456        if fill.read_context && self.context.is_empty() {
457            self.context = read_selinux_context(&self.path);
458        }
459    }
460}
461
462#[cfg(unix)]
463fn file_mode(meta: &Metadata) -> u32 {
464    use std::os::unix::fs::PermissionsExt;
465    meta.permissions().mode()
466}
467
468#[cfg(not(unix))]
469fn file_mode(_meta: &Metadata) -> u32 {
470    0
471}
472
473#[cfg(unix)]
474fn meta_ids(meta: &Metadata) -> (u64, u32, u32, u64, u64, u64, u64, u64) {
475    use std::os::unix::fs::MetadataExt;
476    (
477        meta.nlink(),
478        meta.uid(),
479        meta.gid(),
480        meta.ino(),
481        meta.blocks(),
482        meta.dev(),
483        meta.rdev(),
484        meta.blksize(),
485    )
486}
487
488#[cfg(not(unix))]
489fn meta_ids(meta: &Metadata) -> (u64, u32, u32, u64, u64, u64, u64, u64) {
490    let size = meta.len();
491    let blocks = size.div_ceil(512);
492    (1, 0, 0, 0, blocks, 0, 0, 0)
493}
494
495#[cfg(unix)]
496fn ctime_of(meta: &Metadata) -> Option<SystemTime> {
497    use std::os::unix::fs::MetadataExt;
498    use std::time::Duration;
499    // st_ctime is seconds since epoch; st_ctime_nsec for subsecond.
500    let secs = meta.ctime();
501    if secs < 0 {
502        return None;
503    }
504    let nsec = meta.ctime_nsec();
505    let nsec = if (0..1_000_000_000).contains(&nsec) {
506        nsec as u32
507    } else {
508        0
509    };
510    Some(SystemTime::UNIX_EPOCH + Duration::new(secs as u64, nsec))
511}
512
513#[cfg(not(unix))]
514fn ctime_of(meta: &Metadata) -> Option<SystemTime> {
515    // Windows has no ctime-as-status-change; fall back to modified.
516    meta.modified().ok()
517}
518
519fn dev_major_minor(dev: u64) -> (u32, u32) {
520    #[cfg(target_os = "linux")]
521    {
522        (
523            libc::major(dev as libc::dev_t) as u32,
524            libc::minor(dev as libc::dev_t) as u32,
525        )
526    }
527    #[cfg(not(target_os = "linux"))]
528    {
529        // Portable-ish fallback (historical 8+8 encoding).
530        (((dev >> 8) & 0xff) as u32, (dev & 0xff) as u32)
531    }
532}
533
534fn list_xattr_names(path: &Path) -> Vec<String> {
535    #[cfg(target_os = "linux")]
536    {
537        list_xattr_names_linux(path).unwrap_or_default()
538    }
539    #[cfg(not(target_os = "linux"))]
540    {
541        let _ = path;
542        Vec::new()
543    }
544}
545
546#[cfg(target_os = "linux")]
547fn list_xattr_names_linux(path: &Path) -> Option<Vec<String>> {
548    use std::ffi::CString;
549    use std::os::unix::ffi::OsStrExt;
550
551    let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
552    let size = unsafe { libc::llistxattr(c_path.as_ptr(), std::ptr::null_mut(), 0) };
553    if size < 0 {
554        return None;
555    }
556    if size == 0 {
557        return Some(Vec::new());
558    }
559    let mut buf = vec![0u8; size as usize];
560    let n = unsafe { libc::llistxattr(c_path.as_ptr(), buf.as_mut_ptr().cast(), buf.len()) };
561    if n < 0 {
562        return None;
563    }
564    buf.truncate(n as usize);
565    let mut out = Vec::new();
566    for chunk in buf.split(|&b| b == 0) {
567        if chunk.is_empty() {
568            continue;
569        }
570        if let Ok(s) = std::str::from_utf8(chunk) {
571            out.push(s.to_string());
572        }
573    }
574    Some(out)
575}
576
577/// Best-effort SELinux context via `security.selinux` xattr.
578fn read_selinux_context(path: &Path) -> String {
579    #[cfg(unix)]
580    {
581        read_selinux_context_unix(path).unwrap_or_default()
582    }
583    #[cfg(not(unix))]
584    {
585        let _ = path;
586        String::new()
587    }
588}
589
590#[cfg(target_os = "linux")]
591fn read_selinux_context_unix(path: &Path) -> Option<String> {
592    use std::ffi::CString;
593    use std::os::unix::ffi::OsStrExt;
594
595    let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
596    let name = CString::new("security.selinux").ok()?;
597    // Linux: getxattr(path, name, value, size)
598    let size = unsafe { libc::getxattr(c_path.as_ptr(), name.as_ptr(), std::ptr::null_mut(), 0) };
599    if size <= 0 {
600        return None;
601    }
602    let mut buf = vec![0u8; size as usize];
603    let n = unsafe {
604        libc::getxattr(
605            c_path.as_ptr(),
606            name.as_ptr(),
607            buf.as_mut_ptr().cast(),
608            buf.len(),
609        )
610    };
611    if n <= 0 {
612        return None;
613    }
614    buf.truncate(n as usize);
615    while buf.last() == Some(&0) {
616        buf.pop();
617    }
618    String::from_utf8(buf).ok()
619}
620
621/// macOS/BSD: different getxattr arity; SELinux xattr is typically absent — skip.
622#[cfg(all(unix, not(target_os = "linux")))]
623fn read_selinux_context_unix(_path: &Path) -> Option<String> {
624    None
625}
626
627fn resolve_owner_cached(uid: u32) -> String {
628    if let Ok(cache) = owner_cache().lock() {
629        if let Some(name) = cache.get(&uid) {
630            return name.clone();
631        }
632    }
633    let name = resolve_owner(uid);
634    if let Ok(mut cache) = owner_cache().lock() {
635        cache.insert(uid, name.clone());
636    }
637    name
638}
639
640fn resolve_group_cached(gid: u32) -> String {
641    if let Ok(cache) = group_cache().lock() {
642        if let Some(name) = cache.get(&gid) {
643            return name.clone();
644        }
645    }
646    let name = resolve_group(gid);
647    if let Ok(mut cache) = group_cache().lock() {
648        cache.insert(gid, name.clone());
649    }
650    name
651}
652
653#[cfg(unix)]
654fn resolve_owner(uid: u32) -> String {
655    // SAFETY: getpwuid returns a static pointer for the duration of the call.
656    unsafe {
657        let pw = libc::getpwuid(uid);
658        if pw.is_null() {
659            return uid.to_string();
660        }
661        let name = std::ffi::CStr::from_ptr((*pw).pw_name);
662        name.to_string_lossy().into_owned()
663    }
664}
665
666#[cfg(unix)]
667fn resolve_group(gid: u32) -> String {
668    unsafe {
669        let gr = libc::getgrgid(gid);
670        if gr.is_null() {
671            return gid.to_string();
672        }
673        let name = std::ffi::CStr::from_ptr((*gr).gr_name);
674        name.to_string_lossy().into_owned()
675    }
676}
677
678#[cfg(not(unix))]
679fn resolve_owner(_uid: u32) -> String {
680    String::from("user")
681}
682
683#[cfg(not(unix))]
684fn resolve_group(_gid: u32) -> String {
685    String::from("group")
686}
687
688#[cfg(test)]
689mod meta_fill_tests {
690    use super::*;
691    use std::fs;
692    use std::time::{SystemTime, UNIX_EPOCH};
693
694    fn temp_file() -> std::path::PathBuf {
695        use std::sync::atomic::{AtomicU64, Ordering};
696        static SEQ: AtomicU64 = AtomicU64::new(0);
697        let p = std::env::temp_dir().join(format!(
698            "f00-meta-{}-{}-{}",
699            std::process::id(),
700            SystemTime::now()
701                .duration_since(UNIX_EPOCH)
702                .map(|d| d.as_nanos())
703                .unwrap_or(0),
704            SEQ.fetch_add(1, Ordering::Relaxed)
705        ));
706        fs::write(&p, b"x").unwrap();
707        p
708    }
709
710    #[test]
711    fn cheap_fill_skips_owner_names() {
712        let p = temp_file();
713        let e = Entry::from_path_with(&p, 0, MetaFill::cheap()).unwrap();
714        assert!(e.owner.is_empty(), "cheap path should not resolve owner");
715        assert!(e.context.is_empty());
716        // Size is at least the one byte we wrote (some FS report larger blocks).
717        assert!(e.size >= 1, "size={}", e.size);
718        let _ = fs::remove_file(&p);
719    }
720
721    #[test]
722    fn rich_fill_resolves_owner_on_unix() {
723        let p = temp_file();
724        let e = Entry::from_path_with(&p, 0, MetaFill::rich(false)).unwrap();
725        #[cfg(unix)]
726        assert!(!e.owner.is_empty() || e.uid > 0);
727        #[cfg(not(unix))]
728        let _ = e;
729        let _ = fs::remove_file(&p);
730    }
731}