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#[derive(Debug, Clone, Copy, Default)]
13pub struct MetaFill {
14 pub resolve_names: bool,
16 pub read_context: bool,
18}
19
20impl MetaFill {
21 pub fn rich(read_context: bool) -> Self {
23 Self {
24 resolve_names: true,
25 read_context,
26 }
27 }
28
29 pub fn cheap() -> Self {
31 Self::default()
32 }
33}
34
35fn 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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
127pub enum TimeField {
128 #[default]
129 Modified,
130 Accessed,
131 Changed,
132 Birth,
133}
134
135#[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 pub changed: Option<SystemTime>,
147 pub mode: u32,
149 pub readonly: bool,
150 pub symlink_target: Option<PathBuf>,
151 pub depth: usize,
152 pub git_status: GitStatus,
153 pub is_dir_header: bool,
155 pub nlink: u64,
157 pub uid: u32,
158 pub gid: u32,
159 pub inode: u64,
160 pub blocks: u64,
162 pub dev: u64,
164 pub rdev: u64,
166 pub blksize: u64,
168 pub owner: String,
170 pub group: String,
172 pub author: String,
174 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 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 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 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 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 pub fn dev_major_minor(&self) -> (u32, u32) {
363 dev_major_minor(self.dev)
364 }
365
366 pub fn rdev_major_minor(&self) -> (u32, u32) {
368 dev_major_minor(self.rdev)
369 }
370
371 pub fn xattr_names(&self) -> Vec<String> {
373 list_xattr_names(&self.path)
374 }
375
376 pub fn mode_perms_octal(&self) -> String {
378 format!("{:o}", self.mode & 0o7777)
379 }
380
381 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 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 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 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 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 (((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
577fn 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 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#[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 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 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}