Skip to main content

zsh/
stat.rs

1//! File stat interface - port of Modules/stat.c
2//!
3//! Provides stat/zstat builtin for accessing file metadata.
4
5use std::collections::HashMap;
6use std::fs::{self, Metadata};
7use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
8use std::path::Path;
9use std::time::UNIX_EPOCH;
10
11/// Stat element types
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum StatElement {
14    Device,
15    Inode,
16    Mode,
17    Nlink,
18    Uid,
19    Gid,
20    Rdev,
21    Size,
22    Atime,
23    Mtime,
24    Ctime,
25    Blksize,
26    Blocks,
27    Link,
28}
29
30impl StatElement {
31    pub fn from_name(name: &str) -> Option<Self> {
32        let elements = Self::all();
33        let matches: Vec<_> = elements
34            .iter()
35            .filter(|(n, _)| n.starts_with(name))
36            .collect();
37
38        if matches.len() == 1 {
39            Some(matches[0].1)
40        } else {
41            None
42        }
43    }
44
45    pub fn name(&self) -> &'static str {
46        match self {
47            Self::Device => "device",
48            Self::Inode => "inode",
49            Self::Mode => "mode",
50            Self::Nlink => "nlink",
51            Self::Uid => "uid",
52            Self::Gid => "gid",
53            Self::Rdev => "rdev",
54            Self::Size => "size",
55            Self::Atime => "atime",
56            Self::Mtime => "mtime",
57            Self::Ctime => "ctime",
58            Self::Blksize => "blksize",
59            Self::Blocks => "blocks",
60            Self::Link => "link",
61        }
62    }
63
64    pub fn all() -> Vec<(&'static str, Self)> {
65        vec![
66            ("device", Self::Device),
67            ("inode", Self::Inode),
68            ("mode", Self::Mode),
69            ("nlink", Self::Nlink),
70            ("uid", Self::Uid),
71            ("gid", Self::Gid),
72            ("rdev", Self::Rdev),
73            ("size", Self::Size),
74            ("atime", Self::Atime),
75            ("mtime", Self::Mtime),
76            ("ctime", Self::Ctime),
77            ("blksize", Self::Blksize),
78            ("blocks", Self::Blocks),
79            ("link", Self::Link),
80        ]
81    }
82
83    pub fn list_names() -> Vec<&'static str> {
84        Self::all().into_iter().map(|(n, _)| n).collect()
85    }
86}
87
88/// Stat flags
89#[derive(Debug, Default, Clone)]
90pub struct StatFlags {
91    pub show_name: bool,
92    pub show_file: bool,
93    pub string_format: bool,
94    pub raw_format: bool,
95    pub octal_mode: bool,
96    pub use_gmt: bool,
97    pub use_lstat: bool,
98}
99
100/// File stat info
101#[derive(Debug, Clone)]
102pub struct FileStat {
103    pub device: u64,
104    pub inode: u64,
105    pub mode: u32,
106    pub nlink: u64,
107    pub uid: u32,
108    pub gid: u32,
109    pub rdev: u64,
110    pub size: u64,
111    pub atime: i64,
112    pub mtime: i64,
113    pub ctime: i64,
114    pub blksize: u64,
115    pub blocks: u64,
116    pub link_target: Option<String>,
117    pub file_type: FileType,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum FileType {
122    Regular,
123    Directory,
124    Symlink,
125    BlockDevice,
126    CharDevice,
127    Fifo,
128    Socket,
129    Unknown,
130}
131
132impl FileType {
133    pub fn from_metadata(meta: &Metadata) -> Self {
134        let ft = meta.file_type();
135        if ft.is_file() {
136            Self::Regular
137        } else if ft.is_dir() {
138            Self::Directory
139        } else if ft.is_symlink() {
140            Self::Symlink
141        } else if ft.is_block_device() {
142            Self::BlockDevice
143        } else if ft.is_char_device() {
144            Self::CharDevice
145        } else if ft.is_fifo() {
146            Self::Fifo
147        } else if ft.is_socket() {
148            Self::Socket
149        } else {
150            Self::Unknown
151        }
152    }
153
154    pub fn mode_char(&self) -> char {
155        match self {
156            Self::Regular => '-',
157            Self::Directory => 'd',
158            Self::Symlink => 'l',
159            Self::BlockDevice => 'b',
160            Self::CharDevice => 'c',
161            Self::Fifo => 'p',
162            Self::Socket => 's',
163            Self::Unknown => '?',
164        }
165    }
166}
167
168impl FileStat {
169    pub fn from_path(path: &Path, use_lstat: bool) -> std::io::Result<Self> {
170        let meta = if use_lstat {
171            fs::symlink_metadata(path)?
172        } else {
173            fs::metadata(path)?
174        };
175
176        let link_target = if meta.file_type().is_symlink() {
177            fs::read_link(path)
178                .ok()
179                .map(|p| p.to_string_lossy().to_string())
180        } else {
181            None
182        };
183
184        Ok(Self::from_metadata(&meta, link_target))
185    }
186
187    pub fn from_metadata(meta: &Metadata, link_target: Option<String>) -> Self {
188        let atime = meta
189            .accessed()
190            .ok()
191            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
192            .map(|d| d.as_secs() as i64)
193            .unwrap_or(0);
194
195        let mtime = meta
196            .modified()
197            .ok()
198            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
199            .map(|d| d.as_secs() as i64)
200            .unwrap_or(0);
201
202        Self {
203            device: meta.dev(),
204            inode: meta.ino(),
205            mode: meta.mode(),
206            nlink: meta.nlink(),
207            uid: meta.uid(),
208            gid: meta.gid(),
209            rdev: meta.rdev(),
210            size: meta.size(),
211            atime,
212            mtime,
213            ctime: meta.ctime(),
214            blksize: meta.blksize(),
215            blocks: meta.blocks(),
216            link_target,
217            file_type: FileType::from_metadata(meta),
218        }
219    }
220
221    pub fn get_element(&self, elem: StatElement, flags: &StatFlags) -> String {
222        match elem {
223            StatElement::Device => format!("{}", self.device),
224            StatElement::Inode => format!("{}", self.inode),
225            StatElement::Mode => self.format_mode(flags),
226            StatElement::Nlink => format!("{}", self.nlink),
227            StatElement::Uid => self.format_uid(flags),
228            StatElement::Gid => self.format_gid(flags),
229            StatElement::Rdev => format!("{}", self.rdev),
230            StatElement::Size => format!("{}", self.size),
231            StatElement::Atime => self.format_time(self.atime, flags),
232            StatElement::Mtime => self.format_time(self.mtime, flags),
233            StatElement::Ctime => self.format_time(self.ctime, flags),
234            StatElement::Blksize => format!("{}", self.blksize),
235            StatElement::Blocks => format!("{}", self.blocks),
236            StatElement::Link => self.link_target.clone().unwrap_or_default(),
237        }
238    }
239
240    fn format_mode(&self, flags: &StatFlags) -> String {
241        let mut result = String::new();
242
243        if flags.raw_format {
244            if flags.octal_mode {
245                result.push_str(&format!("0{:o}", self.mode));
246            } else {
247                result.push_str(&format!("{}", self.mode));
248            }
249            if flags.string_format {
250                result.push_str(" (");
251            }
252        }
253
254        if flags.string_format {
255            result.push(self.file_type.mode_char());
256
257            let perms = [
258                (self.mode & 0o400 != 0, 'r'),
259                (self.mode & 0o200 != 0, 'w'),
260                (
261                    self.mode & 0o100 != 0,
262                    if self.mode & 0o4000 != 0 { 's' } else { 'x' },
263                ),
264                (self.mode & 0o040 != 0, 'r'),
265                (self.mode & 0o020 != 0, 'w'),
266                (
267                    self.mode & 0o010 != 0,
268                    if self.mode & 0o2000 != 0 { 's' } else { 'x' },
269                ),
270                (self.mode & 0o004 != 0, 'r'),
271                (self.mode & 0o002 != 0, 'w'),
272                (
273                    self.mode & 0o001 != 0,
274                    if self.mode & 0o1000 != 0 { 't' } else { 'x' },
275                ),
276            ];
277
278            for (set, ch) in perms {
279                if set {
280                    result.push(ch);
281                } else if ch == 's' || ch == 't' {
282                    result.push(ch.to_ascii_uppercase());
283                } else {
284                    result.push('-');
285                }
286            }
287
288            if !set_bit(self.mode, 0o100) && self.mode & 0o4000 != 0 {
289                let chars: Vec<char> = result.chars().collect();
290                let mut r: String = chars[..3].iter().collect();
291                r.push('S');
292                r.push_str(&chars[4..].iter().collect::<String>());
293                result = r;
294            }
295
296            if flags.raw_format {
297                result.push(')');
298            }
299        }
300
301        if !flags.raw_format && !flags.string_format {
302            if flags.octal_mode {
303                result = format!("0{:o}", self.mode);
304            } else {
305                result = format!("{}", self.mode);
306            }
307        }
308
309        result
310    }
311
312    fn format_uid(&self, flags: &StatFlags) -> String {
313        let mut result = String::new();
314
315        if flags.raw_format {
316            result.push_str(&format!("{}", self.uid));
317            if flags.string_format {
318                result.push_str(" (");
319            }
320        }
321
322        if flags.string_format {
323            #[cfg(unix)]
324            {
325                if let Some(name) = get_username(self.uid) {
326                    result.push_str(&name);
327                } else {
328                    result.push_str(&format!("{}", self.uid));
329                }
330            }
331            #[cfg(not(unix))]
332            {
333                result.push_str(&format!("{}", self.uid));
334            }
335
336            if flags.raw_format {
337                result.push(')');
338            }
339        }
340
341        if !flags.raw_format && !flags.string_format {
342            result = format!("{}", self.uid);
343        }
344
345        result
346    }
347
348    fn format_gid(&self, flags: &StatFlags) -> String {
349        let mut result = String::new();
350
351        if flags.raw_format {
352            result.push_str(&format!("{}", self.gid));
353            if flags.string_format {
354                result.push_str(" (");
355            }
356        }
357
358        if flags.string_format {
359            #[cfg(unix)]
360            {
361                if let Some(name) = get_groupname(self.gid) {
362                    result.push_str(&name);
363                } else {
364                    result.push_str(&format!("{}", self.gid));
365                }
366            }
367            #[cfg(not(unix))]
368            {
369                result.push_str(&format!("{}", self.gid));
370            }
371
372            if flags.raw_format {
373                result.push(')');
374            }
375        }
376
377        if !flags.raw_format && !flags.string_format {
378            result = format!("{}", self.gid);
379        }
380
381        result
382    }
383
384    fn format_time(&self, timestamp: i64, flags: &StatFlags) -> String {
385        let mut result = String::new();
386
387        if flags.raw_format {
388            result.push_str(&format!("{}", timestamp));
389            if flags.string_format {
390                result.push_str(" (");
391            }
392        }
393
394        if flags.string_format {
395            use chrono::{Local, TimeZone, Utc};
396
397            let dt = if flags.use_gmt {
398                Utc.timestamp_opt(timestamp, 0)
399                    .single()
400                    .map(|dt| dt.format("%a %b %e %k:%M:%S %Z %Y").to_string())
401            } else {
402                Local
403                    .timestamp_opt(timestamp, 0)
404                    .single()
405                    .map(|dt| dt.format("%a %b %e %k:%M:%S %Z %Y").to_string())
406            };
407
408            result.push_str(&dt.unwrap_or_else(|| format!("{}", timestamp)));
409
410            if flags.raw_format {
411                result.push(')');
412            }
413        }
414
415        if !flags.raw_format && !flags.string_format {
416            result = format!("{}", timestamp);
417        }
418
419        result
420    }
421
422    pub fn to_hash(&self, flags: &StatFlags) -> HashMap<String, String> {
423        let mut map = HashMap::new();
424        for (name, elem) in StatElement::all() {
425            map.insert(name.to_string(), self.get_element(elem, flags));
426        }
427        map
428    }
429
430    pub fn to_array(&self, flags: &StatFlags) -> Vec<String> {
431        StatElement::all()
432            .into_iter()
433            .map(|(_, elem)| self.get_element(elem, flags))
434            .collect()
435    }
436}
437
438fn set_bit(mode: u32, bit: u32) -> bool {
439    mode & bit != 0
440}
441
442#[cfg(unix)]
443fn get_username(uid: u32) -> Option<String> {
444    use std::ffi::CStr;
445    unsafe {
446        let pwd = libc::getpwuid(uid);
447        if pwd.is_null() {
448            None
449        } else {
450            CStr::from_ptr((*pwd).pw_name)
451                .to_str()
452                .ok()
453                .map(|s| s.to_string())
454        }
455    }
456}
457
458#[cfg(unix)]
459fn get_groupname(gid: u32) -> Option<String> {
460    use std::ffi::CStr;
461    unsafe {
462        let grp = libc::getgrgid(gid);
463        if grp.is_null() {
464            None
465        } else {
466            CStr::from_ptr((*grp).gr_name)
467                .to_str()
468                .ok()
469                .map(|s| s.to_string())
470        }
471    }
472}
473
474/// Options for stat builtin
475#[derive(Debug, Default)]
476pub struct StatOptions {
477    pub list_elements: bool,
478    pub use_lstat: bool,
479    pub use_gmt: bool,
480    pub show_name: bool,
481    pub hide_name: bool,
482    pub show_type: bool,
483    pub hide_type: bool,
484    pub raw_format: bool,
485    pub string_format: bool,
486    pub octal_mode: bool,
487    pub element: Option<StatElement>,
488    pub array_name: Option<String>,
489    pub hash_name: Option<String>,
490    pub time_format: Option<String>,
491}
492
493/// Execute the stat builtin
494pub fn builtin_stat(args: &[&str], options: &StatOptions) -> (i32, String) {
495    let mut output = String::new();
496
497    if options.list_elements {
498        let names = StatElement::list_names();
499        output.push_str(&names.join(" "));
500        output.push('\n');
501        return (0, output);
502    }
503
504    if args.is_empty() {
505        return (1, "stat: no files given\n".to_string());
506    }
507
508    let flags = StatFlags {
509        show_name: options.show_type && !options.hide_type,
510        show_file: (options.show_name || args.len() > 1) && !options.hide_name,
511        string_format: options.string_format || options.use_gmt,
512        raw_format: options.raw_format || !options.string_format,
513        octal_mode: options.octal_mode,
514        use_gmt: options.use_gmt,
515        use_lstat: options.use_lstat || options.element == Some(StatElement::Link),
516    };
517
518    let mut ret = 0;
519
520    for path_str in args {
521        let path = Path::new(path_str);
522
523        let stat_result = FileStat::from_path(path, flags.use_lstat);
524
525        match stat_result {
526            Ok(stat) => {
527                if flags.show_file {
528                    if options.element.is_some() {
529                        output.push_str(&format!("{} ", path_str));
530                    } else {
531                        output.push_str(&format!("{}:\n", path_str));
532                    }
533                }
534
535                if let Some(elem) = options.element {
536                    let value = stat.get_element(elem, &flags);
537                    if flags.show_name {
538                        output.push_str(&format!("{} {}\n", elem.name(), value));
539                    } else {
540                        output.push_str(&format!("{}\n", value));
541                    }
542                } else {
543                    for (name, elem) in StatElement::all() {
544                        let value = stat.get_element(elem, &flags);
545                        if flags.show_name {
546                            output.push_str(&format!("{:<8} {}\n", name, value));
547                        } else {
548                            output.push_str(&format!("{}\n", value));
549                        }
550                    }
551                }
552            }
553            Err(e) => {
554                output.push_str(&format!("stat: {}: {}\n", path_str, e));
555                ret = 1;
556            }
557        }
558    }
559
560    (ret, output)
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use std::fs::File;
567    use std::io::Write;
568    use tempfile::TempDir;
569
570    #[test]
571    fn test_stat_element_from_name() {
572        assert_eq!(StatElement::from_name("dev"), Some(StatElement::Device));
573        assert_eq!(StatElement::from_name("device"), Some(StatElement::Device));
574        assert_eq!(StatElement::from_name("mode"), Some(StatElement::Mode));
575        assert_eq!(StatElement::from_name("size"), Some(StatElement::Size));
576        assert_eq!(StatElement::from_name("link"), Some(StatElement::Link));
577        assert_eq!(StatElement::from_name("nonexistent"), None);
578    }
579
580    #[test]
581    fn test_stat_element_list() {
582        let names = StatElement::list_names();
583        assert!(names.contains(&"device"));
584        assert!(names.contains(&"inode"));
585        assert!(names.contains(&"mode"));
586        assert!(names.contains(&"size"));
587        assert_eq!(names.len(), 14);
588    }
589
590    #[test]
591    fn test_file_type_mode_char() {
592        assert_eq!(FileType::Regular.mode_char(), '-');
593        assert_eq!(FileType::Directory.mode_char(), 'd');
594        assert_eq!(FileType::Symlink.mode_char(), 'l');
595        assert_eq!(FileType::BlockDevice.mode_char(), 'b');
596        assert_eq!(FileType::CharDevice.mode_char(), 'c');
597    }
598
599    #[test]
600    fn test_file_stat_from_path() {
601        let dir = TempDir::new().unwrap();
602        let file_path = dir.path().join("test.txt");
603
604        {
605            let mut f = File::create(&file_path).unwrap();
606            f.write_all(b"hello world").unwrap();
607        }
608
609        let stat = FileStat::from_path(&file_path, false).unwrap();
610        assert_eq!(stat.size, 11);
611        assert_eq!(stat.file_type, FileType::Regular);
612        assert!(stat.inode > 0);
613    }
614
615    #[test]
616    fn test_format_mode_string() {
617        let stat = FileStat {
618            device: 0,
619            inode: 0,
620            mode: 0o100644,
621            nlink: 1,
622            uid: 0,
623            gid: 0,
624            rdev: 0,
625            size: 0,
626            atime: 0,
627            mtime: 0,
628            ctime: 0,
629            blksize: 0,
630            blocks: 0,
631            link_target: None,
632            file_type: FileType::Regular,
633        };
634
635        let flags = StatFlags {
636            string_format: true,
637            ..Default::default()
638        };
639
640        let mode_str = stat.format_mode(&flags);
641        assert!(mode_str.starts_with('-'));
642        assert!(mode_str.contains('r'));
643        assert!(mode_str.contains('w'));
644    }
645
646    #[test]
647    fn test_format_mode_octal() {
648        let stat = FileStat {
649            device: 0,
650            inode: 0,
651            mode: 0o100755,
652            nlink: 1,
653            uid: 0,
654            gid: 0,
655            rdev: 0,
656            size: 0,
657            atime: 0,
658            mtime: 0,
659            ctime: 0,
660            blksize: 0,
661            blocks: 0,
662            link_target: None,
663            file_type: FileType::Regular,
664        };
665
666        let flags = StatFlags {
667            raw_format: true,
668            octal_mode: true,
669            ..Default::default()
670        };
671
672        let mode_str = stat.format_mode(&flags);
673        assert!(mode_str.starts_with("0"));
674        assert!(mode_str.contains("755"));
675    }
676
677    #[test]
678    fn test_stat_to_hash() {
679        let stat = FileStat {
680            device: 1,
681            inode: 12345,
682            mode: 0o100644,
683            nlink: 1,
684            uid: 1000,
685            gid: 1000,
686            rdev: 0,
687            size: 100,
688            atime: 1700000000,
689            mtime: 1700000000,
690            ctime: 1700000000,
691            blksize: 4096,
692            blocks: 8,
693            link_target: None,
694            file_type: FileType::Regular,
695        };
696
697        let flags = StatFlags::default();
698        let hash = stat.to_hash(&flags);
699
700        assert!(hash.contains_key("device"));
701        assert!(hash.contains_key("size"));
702        assert_eq!(hash.get("size"), Some(&"100".to_string()));
703    }
704
705    #[test]
706    fn test_builtin_stat_list() {
707        let options = StatOptions {
708            list_elements: true,
709            ..Default::default()
710        };
711
712        let (status, output) = builtin_stat(&[], &options);
713        assert_eq!(status, 0);
714        assert!(output.contains("device"));
715        assert!(output.contains("inode"));
716        assert!(output.contains("mode"));
717    }
718
719    #[test]
720    fn test_builtin_stat_no_args() {
721        let options = StatOptions::default();
722        let (status, output) = builtin_stat(&[], &options);
723        assert_eq!(status, 1);
724        assert!(output.contains("no files given"));
725    }
726
727    #[test]
728    fn test_builtin_stat_file() {
729        let dir = TempDir::new().unwrap();
730        let file_path = dir.path().join("test.txt");
731
732        {
733            let mut f = File::create(&file_path).unwrap();
734            f.write_all(b"test content").unwrap();
735        }
736
737        let options = StatOptions {
738            show_type: true,
739            ..Default::default()
740        };
741
742        let (status, output) = builtin_stat(&[file_path.to_str().unwrap()], &options);
743        assert_eq!(status, 0);
744        assert!(output.contains("device"));
745        assert!(output.contains("size"));
746    }
747}