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    /// Owner name (or numeric string).
163    pub owner: String,
164    /// Group name (or numeric string).
165    pub group: String,
166    /// Author (GNU `--author`); typically same as owner on Linux.
167    pub author: String,
168    /// SELinux security context (`-Z`); empty if unavailable.
169    pub context: String,
170}
171
172impl Entry {
173    pub fn from_path(path: impl AsRef<Path>, depth: usize) -> Result<Self> {
174        Self::from_path_with(path, depth, MetaFill::default())
175    }
176
177    pub fn from_path_with(path: impl AsRef<Path>, depth: usize, fill: MetaFill) -> Result<Self> {
178        let path = path.as_ref();
179        let meta = std::fs::symlink_metadata(path).map_err(|source| Error::Metadata {
180            path: path.to_path_buf(),
181            source,
182        })?;
183        Self::from_path_and_meta_with(path, &meta, depth, fill)
184    }
185
186    /// Like [`from_path`] but follow the final symlink target for metadata (`-L`).
187    pub fn from_path_follow(path: impl AsRef<Path>, depth: usize) -> Result<Self> {
188        Self::from_path_follow_with(path, depth, MetaFill::default())
189    }
190
191    pub fn from_path_follow_with(
192        path: impl AsRef<Path>,
193        depth: usize,
194        fill: MetaFill,
195    ) -> Result<Self> {
196        let path = path.as_ref();
197        let meta = std::fs::metadata(path).map_err(|source| Error::Metadata {
198            path: path.to_path_buf(),
199            source,
200        })?;
201        let mut entry = Self::from_path_and_meta_with(path, &meta, depth, fill)?;
202        // Keep symlink name but drop "-> target" when showing dereference.
203        if entry.kind == EntryKind::Symlink {
204            entry.kind = EntryKind::from_file_type(meta.file_type());
205            entry.symlink_target = None;
206        }
207        Ok(entry)
208    }
209
210    pub fn from_path_and_meta(path: &Path, meta: &Metadata, depth: usize) -> Result<Self> {
211        Self::from_path_and_meta_with(path, meta, depth, MetaFill::default())
212    }
213
214    pub fn from_path_and_meta_with(
215        path: &Path,
216        meta: &Metadata,
217        depth: usize,
218        fill: MetaFill,
219    ) -> Result<Self> {
220        let file_type = meta.file_type();
221        let kind = EntryKind::from_file_type(file_type);
222        let name = path
223            .file_name()
224            .map(|s| s.to_string_lossy().into_owned())
225            .unwrap_or_else(|| path.to_string_lossy().into_owned());
226
227        let symlink_target = if file_type.is_symlink() {
228            std::fs::read_link(path).ok()
229        } else {
230            None
231        };
232
233        let mode = file_mode(meta);
234        let (nlink, uid, gid, inode, blocks) = meta_ids(meta);
235        let (owner, group) = if fill.resolve_names {
236            (resolve_owner_cached(uid), resolve_group_cached(gid))
237        } else {
238            (String::new(), String::new())
239        };
240        let changed = ctime_of(meta);
241        let context = if fill.read_context {
242            read_selinux_context(path)
243        } else {
244            String::new()
245        };
246
247        Ok(Self {
248            path: path.to_path_buf(),
249            name,
250            kind,
251            size: meta.len(),
252            modified: meta.modified().ok(),
253            created: meta.created().ok(),
254            accessed: meta.accessed().ok(),
255            changed,
256            mode,
257            readonly: meta.permissions().readonly(),
258            symlink_target,
259            depth,
260            git_status: GitStatus::Clean,
261            is_dir_header: false,
262            nlink,
263            uid,
264            gid,
265            inode,
266            blocks,
267            author: owner.clone(),
268            owner,
269            group,
270            context,
271        })
272    }
273
274    /// Create a synthetic directory header for recursive listings.
275    pub fn dir_header(path: impl AsRef<Path>, depth: usize) -> Self {
276        let path = path.as_ref().to_path_buf();
277        let name = path.to_string_lossy().into_owned();
278        Self {
279            path,
280            name,
281            kind: EntryKind::Directory,
282            size: 0,
283            modified: None,
284            created: None,
285            accessed: None,
286            changed: None,
287            mode: 0,
288            readonly: false,
289            symlink_target: None,
290            depth,
291            git_status: GitStatus::Clean,
292            is_dir_header: true,
293            nlink: 0,
294            uid: 0,
295            gid: 0,
296            inode: 0,
297            blocks: 0,
298            owner: String::new(),
299            group: String::new(),
300            author: String::new(),
301            context: String::new(),
302        }
303    }
304
305    pub fn is_hidden(&self) -> bool {
306        self.name.starts_with('.')
307    }
308
309    pub fn is_dir(&self) -> bool {
310        self.kind == EntryKind::Directory
311    }
312
313    pub fn modified_datetime(&self) -> Option<DateTime<Local>> {
314        self.modified.map(DateTime::<Local>::from)
315    }
316
317    pub fn accessed_datetime(&self) -> Option<DateTime<Local>> {
318        self.accessed.map(DateTime::<Local>::from)
319    }
320
321    pub fn created_datetime(&self) -> Option<DateTime<Local>> {
322        self.created.map(DateTime::<Local>::from)
323    }
324
325    pub fn changed_datetime(&self) -> Option<DateTime<Local>> {
326        self.changed.map(DateTime::<Local>::from)
327    }
328
329    pub fn time_for(&self, field: TimeField) -> Option<SystemTime> {
330        match field {
331            TimeField::Modified => self.modified,
332            TimeField::Accessed => self.accessed,
333            TimeField::Changed => self.changed.or(self.modified),
334            TimeField::Birth => self.created.or(self.modified),
335        }
336    }
337
338    pub fn datetime_for(&self, field: TimeField) -> Option<DateTime<Local>> {
339        self.time_for(field).map(DateTime::<Local>::from)
340    }
341
342    pub fn extension(&self) -> Option<&str> {
343        Path::new(&self.name).extension().and_then(|e| e.to_str())
344    }
345
346    /// Owner string for display (`-n` forces numeric).
347    pub fn owner_display(&self, numeric: bool) -> String {
348        if numeric {
349            self.uid.to_string()
350        } else {
351            self.owner.clone()
352        }
353    }
354
355    pub fn group_display(&self, numeric: bool) -> String {
356        if numeric {
357            self.gid.to_string()
358        } else {
359            self.group.clone()
360        }
361    }
362
363    pub fn author_display(&self, numeric: bool) -> String {
364        if numeric {
365            self.uid.to_string()
366        } else if !self.author.is_empty() {
367            self.author.clone()
368        } else {
369            self.owner_display(numeric)
370        }
371    }
372}
373
374#[cfg(unix)]
375fn file_mode(meta: &Metadata) -> u32 {
376    use std::os::unix::fs::PermissionsExt;
377    meta.permissions().mode()
378}
379
380#[cfg(not(unix))]
381fn file_mode(_meta: &Metadata) -> u32 {
382    0
383}
384
385#[cfg(unix)]
386fn meta_ids(meta: &Metadata) -> (u64, u32, u32, u64, u64) {
387    use std::os::unix::fs::MetadataExt;
388    (
389        meta.nlink(),
390        meta.uid(),
391        meta.gid(),
392        meta.ino(),
393        meta.blocks(),
394    )
395}
396
397#[cfg(not(unix))]
398fn meta_ids(meta: &Metadata) -> (u64, u32, u32, u64, u64) {
399    let size = meta.len();
400    let blocks = size.div_ceil(512);
401    (1, 0, 0, 0, blocks)
402}
403
404#[cfg(unix)]
405fn ctime_of(meta: &Metadata) -> Option<SystemTime> {
406    use std::os::unix::fs::MetadataExt;
407    use std::time::Duration;
408    // st_ctime is seconds since epoch; st_ctime_nsec for subsecond.
409    let secs = meta.ctime();
410    if secs < 0 {
411        return None;
412    }
413    let nsec = meta.ctime_nsec();
414    let nsec = if (0..1_000_000_000).contains(&nsec) {
415        nsec as u32
416    } else {
417        0
418    };
419    Some(SystemTime::UNIX_EPOCH + Duration::new(secs as u64, nsec))
420}
421
422#[cfg(not(unix))]
423fn ctime_of(meta: &Metadata) -> Option<SystemTime> {
424    // Windows has no ctime-as-status-change; fall back to modified.
425    meta.modified().ok()
426}
427
428/// Best-effort SELinux context via `security.selinux` xattr.
429fn read_selinux_context(path: &Path) -> String {
430    #[cfg(unix)]
431    {
432        read_selinux_context_unix(path).unwrap_or_default()
433    }
434    #[cfg(not(unix))]
435    {
436        let _ = path;
437        String::new()
438    }
439}
440
441#[cfg(target_os = "linux")]
442fn read_selinux_context_unix(path: &Path) -> Option<String> {
443    use std::ffi::CString;
444    use std::os::unix::ffi::OsStrExt;
445
446    let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
447    let name = CString::new("security.selinux").ok()?;
448    // Linux: getxattr(path, name, value, size)
449    let size = unsafe { libc::getxattr(c_path.as_ptr(), name.as_ptr(), std::ptr::null_mut(), 0) };
450    if size <= 0 {
451        return None;
452    }
453    let mut buf = vec![0u8; size as usize];
454    let n = unsafe {
455        libc::getxattr(
456            c_path.as_ptr(),
457            name.as_ptr(),
458            buf.as_mut_ptr().cast(),
459            buf.len(),
460        )
461    };
462    if n <= 0 {
463        return None;
464    }
465    buf.truncate(n as usize);
466    while buf.last() == Some(&0) {
467        buf.pop();
468    }
469    String::from_utf8(buf).ok()
470}
471
472/// macOS/BSD: different getxattr arity; SELinux xattr is typically absent — skip.
473#[cfg(all(unix, not(target_os = "linux")))]
474fn read_selinux_context_unix(_path: &Path) -> Option<String> {
475    None
476}
477
478fn resolve_owner_cached(uid: u32) -> String {
479    if let Ok(cache) = owner_cache().lock() {
480        if let Some(name) = cache.get(&uid) {
481            return name.clone();
482        }
483    }
484    let name = resolve_owner(uid);
485    if let Ok(mut cache) = owner_cache().lock() {
486        cache.insert(uid, name.clone());
487    }
488    name
489}
490
491fn resolve_group_cached(gid: u32) -> String {
492    if let Ok(cache) = group_cache().lock() {
493        if let Some(name) = cache.get(&gid) {
494            return name.clone();
495        }
496    }
497    let name = resolve_group(gid);
498    if let Ok(mut cache) = group_cache().lock() {
499        cache.insert(gid, name.clone());
500    }
501    name
502}
503
504#[cfg(unix)]
505fn resolve_owner(uid: u32) -> String {
506    // SAFETY: getpwuid returns a static pointer for the duration of the call.
507    unsafe {
508        let pw = libc::getpwuid(uid);
509        if pw.is_null() {
510            return uid.to_string();
511        }
512        let name = std::ffi::CStr::from_ptr((*pw).pw_name);
513        name.to_string_lossy().into_owned()
514    }
515}
516
517#[cfg(unix)]
518fn resolve_group(gid: u32) -> String {
519    unsafe {
520        let gr = libc::getgrgid(gid);
521        if gr.is_null() {
522            return gid.to_string();
523        }
524        let name = std::ffi::CStr::from_ptr((*gr).gr_name);
525        name.to_string_lossy().into_owned()
526    }
527}
528
529#[cfg(not(unix))]
530fn resolve_owner(_uid: u32) -> String {
531    String::from("user")
532}
533
534#[cfg(not(unix))]
535fn resolve_group(_gid: u32) -> String {
536    String::from("group")
537}
538
539#[cfg(test)]
540mod meta_fill_tests {
541    use super::*;
542    use std::fs;
543    use std::time::{SystemTime, UNIX_EPOCH};
544
545    fn temp_file() -> std::path::PathBuf {
546        let p = std::env::temp_dir().join(format!(
547            "f00-meta-{}-{}",
548            std::process::id(),
549            SystemTime::now()
550                .duration_since(UNIX_EPOCH)
551                .map(|d| d.as_nanos())
552                .unwrap_or(0)
553        ));
554        fs::write(&p, b"x").unwrap();
555        p
556    }
557
558    #[test]
559    fn cheap_fill_skips_owner_names() {
560        let p = temp_file();
561        let e = Entry::from_path_with(&p, 0, MetaFill::cheap()).unwrap();
562        assert!(e.owner.is_empty(), "cheap path should not resolve owner");
563        assert!(e.context.is_empty());
564        assert_eq!(e.size, 1);
565        let _ = fs::remove_file(&p);
566    }
567
568    #[test]
569    fn rich_fill_resolves_owner_on_unix() {
570        let p = temp_file();
571        let e = Entry::from_path_with(&p, 0, MetaFill::rich(false)).unwrap();
572        #[cfg(unix)]
573        assert!(!e.owner.is_empty() || e.uid > 0);
574        #[cfg(not(unix))]
575        let _ = e;
576        let _ = fs::remove_file(&p);
577    }
578}