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 owner: String,
164 pub group: String,
166 pub author: String,
168 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 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 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 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 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 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 meta.modified().ok()
426}
427
428fn 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 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#[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 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}