Skip to main content

nu_command/filesystem/
ls.rs

1use crate::{DirBuilder, DirInfo};
2use chrono::{DateTime, Local, LocalResult, TimeZone, Utc};
3use nu_engine::{command_prelude::*, glob_from};
4use nu_glob::MatchOptions;
5use nu_path::{expand_path_with, expand_to_real_path};
6use nu_protocol::{
7    NuGlob, PipelineMetadata, Signals,
8    shell_error::{self, generic::GenericError, io::IoError},
9};
10use pathdiff::diff_paths;
11use rayon::prelude::*;
12#[cfg(unix)]
13use std::os::unix::fs::PermissionsExt;
14use std::{
15    cmp::Ordering,
16    fs::{DirEntry, Metadata},
17    path::PathBuf,
18    sync::{Arc, Mutex, mpsc},
19    time::{SystemTime, UNIX_EPOCH},
20};
21
22/// Entry from directory listing with cached metadata/file type to avoid repeated syscalls.
23/// On Windows, DirEntry::metadata() is free (no extra syscalls).
24/// On Unix, DirEntry::file_type() is usually free, but metadata requires stat().
25struct LsEntry {
26    path: PathBuf,
27    /// Cached metadata - on Windows this is free from DirEntry, on Unix we may need to fetch it later
28    #[cfg(windows)]
29    metadata: Option<Metadata>,
30    /// Cached file type - free on most platforms from DirEntry::file_type()
31    #[cfg(not(windows))]
32    file_type: Option<std::fs::FileType>,
33}
34
35impl LsEntry {
36    fn from_dir_entry(entry: &DirEntry) -> Self {
37        let path = entry.path();
38        #[cfg(windows)]
39        {
40            // On Windows, DirEntry::metadata() is free (no extra syscalls)
41            let metadata = entry.metadata().ok();
42            LsEntry { path, metadata }
43        }
44        #[cfg(not(windows))]
45        {
46            // On Unix, DirEntry::file_type() is free, but metadata requires stat()
47            let file_type = entry.file_type().ok();
48            LsEntry { path, file_type }
49        }
50    }
51
52    fn from_path(path: PathBuf) -> Self {
53        LsEntry {
54            path,
55            #[cfg(windows)]
56            metadata: None,
57            #[cfg(not(windows))]
58            file_type: None,
59        }
60    }
61
62    /// Check if this is a directory. Uses cached info if available.
63    fn is_dir(&self) -> bool {
64        #[cfg(windows)]
65        {
66            if let Some(ref md) = self.metadata {
67                return md.is_dir();
68            }
69        }
70        #[cfg(not(windows))]
71        {
72            if let Some(ref ft) = self.file_type {
73                return ft.is_dir();
74            }
75        }
76        // Fallback: need to query
77        self.path
78            .symlink_metadata()
79            .map(|m| m.file_type().is_dir())
80            .unwrap_or(false)
81    }
82
83    /// Check if this is hidden on the current platform.
84    #[cfg(windows)]
85    fn is_hidden(&self) -> bool {
86        use std::os::windows::fs::MetadataExt;
87        // https://docs.microsoft.com/en-us/windows/win32/fileio/file-attribute-constants
88        const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
89        if let Some(ref md) = self.metadata {
90            (md.file_attributes() & FILE_ATTRIBUTE_HIDDEN) != 0
91        } else {
92            // Fallback
93            self.path
94                .metadata()
95                .map(|m| (m.file_attributes() & FILE_ATTRIBUTE_HIDDEN) != 0)
96                .unwrap_or(false)
97        }
98    }
99
100    #[cfg(not(windows))]
101    fn is_hidden(&self) -> bool {
102        self.path
103            .file_name()
104            .map(|name| name.to_string_lossy().starts_with('.'))
105            .unwrap_or(false)
106    }
107
108    /// Get metadata, fetching it if not cached.
109    /// On Windows this should always be cached from DirEntry.
110    /// On Unix this will call symlink_metadata() if needed.
111    fn get_metadata(&self) -> Option<Metadata> {
112        #[cfg(windows)]
113        {
114            // If metadata was cached from DirEntry, use it; otherwise fetch it
115            // (needed for entries created via from_path, e.g., from glob results)
116            if self.metadata.is_some() {
117                self.metadata.clone()
118            } else {
119                std::fs::symlink_metadata(&self.path).ok()
120            }
121        }
122        #[cfg(not(windows))]
123        {
124            std::fs::symlink_metadata(&self.path).ok()
125        }
126    }
127}
128
129#[derive(Clone)]
130pub struct Ls;
131
132#[derive(Clone, Copy)]
133struct Args {
134    all: bool,
135    long: bool,
136    short_names: bool,
137    full_paths: bool,
138    du: bool,
139    directory: bool,
140    use_mime_type: bool,
141    use_threads: bool,
142    call_span: Span,
143}
144
145impl Command for Ls {
146    fn name(&self) -> &str {
147        "ls"
148    }
149
150    fn description(&self) -> &str {
151        "List the filenames, sizes, and modification times of items in a directory."
152    }
153
154    fn search_terms(&self) -> Vec<&str> {
155        vec!["dir"]
156    }
157
158    fn signature(&self) -> nu_protocol::Signature {
159        Signature::build("ls")
160            .input_output_types(vec![(Type::Nothing, Type::table())])
161            // LsGlobPattern is similar to string, it won't auto-expand
162            // and we use it to track if the user input is quoted.
163            .rest("pattern", SyntaxShape::OneOf(vec![SyntaxShape::GlobPattern, SyntaxShape::String]), "The glob pattern to use.")
164            .switch("all", "Show hidden files.", Some('a'))
165            .switch(
166                "long",
167                "Get all available columns for each entry (slower; columns are platform-dependent).",
168                Some('l'),
169            )
170            .switch(
171                "short-names",
172                "Only print the file names, and not the path.",
173                Some('s'),
174            )
175            .switch("full-paths", "Display paths as absolute paths.", Some('f'))
176            .switch(
177                "du",
178                "Display the apparent directory size (\"disk usage\") in place of the directory metadata size.",
179                Some('d'),
180            )
181            .switch(
182                "directory",
183                "List the specified directory itself instead of its contents.",
184                Some('D'),
185            )
186            .switch("mime-type", "Show mime-type in type column instead of 'file' (based on filenames only; files' contents are not examined).", Some('m'))
187            .switch("threads", "Use multiple threads to list contents. Output will be non-deterministic.", Some('t'))
188            .category(Category::FileSystem)
189    }
190
191    fn run(
192        &self,
193        engine_state: &EngineState,
194        stack: &mut Stack,
195        call: &Call,
196        _input: PipelineData,
197    ) -> Result<PipelineData, ShellError> {
198        let all = call.has_flag(engine_state, stack, "all")?;
199        let long = call.has_flag(engine_state, stack, "long")?;
200        let short_names = call.has_flag(engine_state, stack, "short-names")?;
201        let full_paths = call.has_flag(engine_state, stack, "full-paths")?;
202        let du = call.has_flag(engine_state, stack, "du")?;
203        let directory = call.has_flag(engine_state, stack, "directory")?;
204        let use_mime_type = call.has_flag(engine_state, stack, "mime-type")?;
205        let use_threads = call.has_flag(engine_state, stack, "threads")?;
206        let call_span = call.head;
207        let cwd = engine_state.cwd(Some(stack))?.into_std_path_buf();
208
209        let args = Args {
210            all,
211            long,
212            short_names,
213            full_paths,
214            du,
215            directory,
216            use_mime_type,
217            use_threads,
218            call_span,
219        };
220
221        let pattern_arg = call.rest::<Spanned<NuGlob>>(engine_state, stack, 0)?;
222        let input_pattern_arg = if !call.has_positional_args(stack, 0) {
223            None
224        } else {
225            Some(pattern_arg)
226        };
227        match input_pattern_arg {
228            None => Ok(
229                ls_for_one_pattern(None, args, engine_state.signals().clone(), cwd)?
230                    .into_pipeline_data_with_metadata(
231                        call_span,
232                        engine_state.signals().clone(),
233                        ls_pipeline_metadata(call_span, long),
234                    ),
235            ),
236            Some(pattern) => {
237                let mut result_iters = vec![];
238                for pat in pattern {
239                    result_iters.push(ls_for_one_pattern(
240                        Some(pat),
241                        args,
242                        engine_state.signals().clone(),
243                        cwd.clone(),
244                    )?)
245                }
246
247                // Here nushell needs to use
248                // use `flatten` to chain all iterators into one.
249                Ok(result_iters
250                    .into_iter()
251                    .flatten()
252                    .into_pipeline_data_with_metadata(
253                        call_span,
254                        engine_state.signals().clone(),
255                        ls_pipeline_metadata(call_span, long),
256                    ))
257            }
258        }
259    }
260
261    fn examples(&self) -> Vec<Example<'_>> {
262        vec![
263            Example {
264                description: "List visible files in the current directory.",
265                example: "ls",
266                result: None,
267            },
268            Example {
269                description: "List visible files in a subdirectory.",
270                example: "ls subdir",
271                result: None,
272            },
273            Example {
274                description: "List visible files with full path in the parent directory.",
275                example: "ls -f ..",
276                result: None,
277            },
278            Example {
279                description: "List Rust files.",
280                example: "ls *.rs",
281                result: None,
282            },
283            Example {
284                description: "List files and directories whose name do not contain 'bar'.",
285                example: "ls | where name !~ bar",
286                result: None,
287            },
288            Example {
289                description: "List the full path of all dirs in your home directory.",
290                example: "ls -a ~ | where type == dir",
291                result: None,
292            },
293            Example {
294                description: "List only the names (not paths) of all dirs in your home directory which have not been modified in 7 days.",
295                example: "ls -as ~ | where type == dir and modified < ((date now) - 7day)",
296                result: None,
297            },
298            Example {
299                description: "Recursively list all files and subdirectories under the current directory using a glob pattern.",
300                example: "ls -a **/*",
301                result: None,
302            },
303            Example {
304                description: "Recursively list *.rs and *.toml files using the glob command.",
305                example: "ls ...(glob **/*.{rs,toml})",
306                result: None,
307            },
308            Example {
309                description: "List given paths and show directories themselves.",
310                example: "['/path/to/directory' '/path/to/file'] | each {|| ls -D $in } | flatten",
311                result: None,
312            },
313        ]
314    }
315}
316
317/// Builds `ls` output metadata, including width-priority hints for compact views.
318fn ls_pipeline_metadata(span: Span, long: bool) -> PipelineMetadata {
319    let mut metadata = PipelineMetadata {
320        path_columns: vec!["name".to_string()],
321        ..Default::default()
322    };
323
324    // Keep long listings close to legacy layout; priority hints are most useful in compact views.
325    if !long {
326        metadata.set_table_width_priority_columns(span, ["name"]);
327    }
328
329    metadata
330}
331
332fn ls_for_one_pattern(
333    pattern_arg: Option<Spanned<NuGlob>>,
334    args: Args,
335    signals: Signals,
336    cwd: PathBuf,
337) -> Result<PipelineData, ShellError> {
338    fn create_pool(num_threads: usize, call_span: Span) -> Result<rayon::ThreadPool, ShellError> {
339        match rayon::ThreadPoolBuilder::new()
340            .num_threads(num_threads)
341            .build()
342        {
343            Err(e) => Err(e).map_err(|e| {
344                ShellError::Generic(GenericError::new(
345                    "Error creating thread pool",
346                    e.to_string(),
347                    call_span,
348                ))
349            }),
350            Ok(pool) => Ok(pool),
351        }
352    }
353
354    let (tx, rx) = mpsc::channel();
355
356    let Args {
357        all,
358        long,
359        short_names,
360        full_paths,
361        du,
362        directory,
363        use_mime_type,
364        use_threads,
365        call_span,
366    } = args;
367    let pattern_arg = {
368        if let Some(path) = pattern_arg {
369            // it makes no sense to list an empty string.
370            if path.item.as_ref().is_empty() {
371                return Err(ShellError::Io(IoError::new_with_additional_context(
372                    shell_error::io::ErrorKind::from_std(std::io::ErrorKind::NotFound),
373                    path.span,
374                    PathBuf::from(path.item.to_string()),
375                    "empty string('') directory or file does not exist",
376                )));
377            }
378            Some(path.map(NuGlob::strip_ansi_string_unlikely))
379        } else {
380            pattern_arg
381        }
382    };
383
384    let mut just_read_dir = false;
385    let p_tag: Span = pattern_arg.as_ref().map(|p| p.span).unwrap_or(call_span);
386    let (pattern_arg, absolute_path) = match pattern_arg {
387        Some(pat) => {
388            // expand with cwd here is only used for checking
389            let tmp_expanded =
390                nu_path::expand_path_with(pat.item.as_ref(), &cwd, pat.item.is_expand());
391            // Avoid checking and pushing "*" to the path when directory (do not show contents) flag is true
392            if !directory && tmp_expanded.is_dir() {
393                if read_dir(tmp_expanded, p_tag, use_threads, signals.clone())?
394                    .next()
395                    .is_none()
396                {
397                    return Ok(Value::test_nothing().into_pipeline_data());
398                }
399                just_read_dir =
400                    !(pat.item.is_expand() && nu_glob::is_glob_with_backend(pat.item.as_ref()));
401            }
402
403            // it's absolute path if:
404            // 1. pattern is absolute.
405            // 2. pattern can be expanded, and after expands to real_path, it's absolute.
406            //    here `expand_to_real_path` call is required, because `~/aaa` should be absolute
407            //    path.
408            let absolute_path = Path::new(pat.item.as_ref()).is_absolute()
409                || (pat.item.is_expand() && expand_to_real_path(pat.item.as_ref()).is_absolute());
410            (pat.item, absolute_path)
411        }
412        None => {
413            // Avoid pushing "*" to the default path when directory (do not show contents) flag is true
414            if directory {
415                (NuGlob::Expand(".".to_string()), false)
416            } else if read_dir(cwd.clone(), p_tag, use_threads, signals.clone())?
417                .next()
418                .is_none()
419            {
420                return Ok(Value::test_nothing().into_pipeline_data());
421            } else {
422                (NuGlob::Expand("*".to_string()), false)
423            }
424        }
425    };
426
427    let hidden_dir_specified = is_hidden_dir(pattern_arg.as_ref());
428
429    let path = pattern_arg.into_spanned(p_tag);
430    let (prefix, paths): (
431        Option<PathBuf>,
432        Box<dyn Iterator<Item = Result<LsEntry, ShellError>> + Send>,
433    ) = if just_read_dir {
434        let expanded = nu_path::expand_path_with(path.item.as_ref(), &cwd, path.item.is_expand());
435        let paths = read_dir(expanded.clone(), p_tag, use_threads, signals.clone())?;
436        // just need to read the directory, so prefix is path itself.
437        (Some(expanded), paths)
438    } else {
439        let glob_options = if all {
440            None
441        } else {
442            let glob_options = MatchOptions {
443                recursive_match_hidden_dir: false,
444                ..Default::default()
445            };
446            Some(glob_options)
447        };
448        let (prefix, glob_paths) =
449            glob_from(&path, &cwd, call_span, glob_options, signals.clone())?;
450        // Convert PathBuf results to LsEntry (without cached file type from glob)
451        let paths = glob_paths.map(|r| r.map(LsEntry::from_path));
452        (prefix, Box::new(paths))
453    };
454
455    let mut paths_peek = paths.peekable();
456    let no_matches = paths_peek.peek().is_none();
457    signals.check(&call_span)?;
458    if no_matches {
459        return Err(ShellError::Generic(
460            GenericError::new(
461                format!("No matches found for {:?}", path.item),
462                "Pattern, file or folder not found",
463                p_tag,
464            )
465            .with_help("no matches found"),
466        ));
467    }
468
469    let hidden_dirs = Arc::new(Mutex::new(Vec::new()));
470
471    let signals_clone = signals.clone();
472
473    let pool = if use_threads {
474        let count = std::thread::available_parallelism()
475            .map_err(|err| {
476                IoError::new_with_additional_context(
477                    err,
478                    call_span,
479                    None,
480                    "Could not get available parallelism",
481                )
482            })?
483            .get();
484        create_pool(count, call_span)?
485    } else {
486        create_pool(1, call_span)?
487    };
488
489    pool.install(|| {
490        rayon::spawn(move || {
491            let result = paths_peek
492                .par_bridge()
493                .filter_map(move |x| match x {
494                    Ok(entry) => {
495                        let hidden_dir_clone = Arc::clone(&hidden_dirs);
496                        let mut hidden_dir_mutex = hidden_dir_clone
497                            .lock()
498                            .expect("Unable to acquire lock for hidden_dirs");
499                        if path_contains_hidden_folder(&entry.path, &hidden_dir_mutex) {
500                            return None;
501                        }
502
503                        if !all && !hidden_dir_specified && entry.is_hidden() {
504                            if entry.is_dir() {
505                                hidden_dir_mutex.push(entry.path.clone());
506                                drop(hidden_dir_mutex);
507                            }
508                            return None;
509                        }
510                        // Get reference to path first for display_name calculation
511                        let path = &entry.path;
512
513                        let display_name = if short_names {
514                            path.file_name().map(|os| os.to_string_lossy().to_string())
515                        } else if full_paths || absolute_path {
516                            Some(path.to_string_lossy().to_string())
517                        } else if let Some(prefix) = &prefix {
518                            if let Ok(remainder) = path.strip_prefix(prefix) {
519                                if directory {
520                                    // When the path is the same as the cwd, path_diff should be "."
521                                    let path_diff = if let Some(path_diff_not_dot) =
522                                        diff_paths(path, &cwd)
523                                    {
524                                        let path_diff_not_dot = path_diff_not_dot.to_string_lossy();
525                                        if path_diff_not_dot.is_empty() {
526                                            ".".to_string()
527                                        } else {
528                                            path_diff_not_dot.to_string()
529                                        }
530                                    } else {
531                                        path.to_string_lossy().to_string()
532                                    };
533
534                                    Some(path_diff)
535                                } else {
536                                    let new_prefix = if let Some(pfx) = diff_paths(prefix, &cwd) {
537                                        pfx
538                                    } else {
539                                        prefix.to_path_buf()
540                                    };
541
542                                    // Bare trailing `**` (dc-glob) can match the
543                                    // start dir itself; after stripping the prefix
544                                    // that is an empty relative path → show ".".
545                                    let joined = new_prefix.join(remainder);
546                                    if joined.as_os_str().is_empty() {
547                                        Some(".".to_string())
548                                    } else {
549                                        Some(joined.to_string_lossy().to_string())
550                                    }
551                                }
552                            } else {
553                                Some(path.to_string_lossy().to_string())
554                            }
555                        } else {
556                            Some(path.to_string_lossy().to_string())
557                        }
558                        .ok_or_else(|| {
559                            ShellError::Generic(GenericError::new(
560                                format!("Invalid file name: {:}", path.to_string_lossy()),
561                                "invalid file name",
562                                call_span,
563                            ))
564                        });
565
566                        match display_name {
567                            Ok(name) => {
568                                // Use cached metadata from LsEntry when available (free on Windows)
569                                // On Unix, this will call symlink_metadata() but only once per entry
570                                let metadata = entry.get_metadata();
571                                // When full_paths is enabled, ensure path is absolute for symlink target expansion
572                                let path_for_dict = if full_paths && !path.is_absolute() {
573                                    std::borrow::Cow::Owned(cwd.join(path))
574                                } else {
575                                    std::borrow::Cow::Borrowed(path)
576                                };
577                                let result = dir_entry_dict(
578                                    &path_for_dict,
579                                    &name,
580                                    metadata.as_ref(),
581                                    call_span,
582                                    long,
583                                    du,
584                                    &signals_clone,
585                                    use_mime_type,
586                                    full_paths,
587                                );
588                                match result {
589                                    Ok(value) => Some(value),
590                                    Err(err) => Some(Value::error(err, call_span)),
591                                }
592                            }
593                            Err(err) => Some(Value::error(err, call_span)),
594                        }
595                    }
596                    Err(err) => Some(Value::error(err, call_span)),
597                })
598                .try_for_each(|stream| {
599                    tx.send(stream).map_err(|e| {
600                        ShellError::Generic(GenericError::new(
601                            "Error streaming data",
602                            e.to_string(),
603                            call_span,
604                        ))
605                    })
606                })
607                .map_err(|err| {
608                    ShellError::Generic(GenericError::new(
609                        "Unable to create a rayon pool",
610                        err.to_string(),
611                        call_span,
612                    ))
613                });
614
615            if let Err(error) = result {
616                let _ = tx.send(Value::error(error, call_span));
617            }
618        });
619    });
620
621    Ok(rx
622        .into_iter()
623        .into_pipeline_data(call_span, signals.clone()))
624}
625
626fn is_hidden_dir(dir: impl AsRef<Path>) -> bool {
627    #[cfg(windows)]
628    {
629        use std::os::windows::fs::MetadataExt;
630
631        if let Ok(metadata) = dir.as_ref().metadata() {
632            let attributes = metadata.file_attributes();
633            // https://docs.microsoft.com/en-us/windows/win32/fileio/file-attribute-constants
634            (attributes & 0x2) != 0
635        } else {
636            false
637        }
638    }
639
640    #[cfg(not(windows))]
641    {
642        dir.as_ref()
643            .file_name()
644            .map(|name| name.to_string_lossy().starts_with('.'))
645            .unwrap_or(false)
646    }
647}
648
649fn path_contains_hidden_folder(path: &Path, folders: &[PathBuf]) -> bool {
650    if folders.iter().any(|p| path.starts_with(p.as_path())) {
651        return true;
652    }
653    false
654}
655
656#[cfg(unix)]
657use std::os::unix::fs::FileTypeExt;
658use std::path::Path;
659
660pub fn get_file_type(md: &std::fs::Metadata, display_name: &str, use_mime_type: bool) -> String {
661    let ft = md.file_type();
662    let mut file_type = "unknown";
663    if ft.is_dir() {
664        file_type = "dir";
665    } else if ft.is_file() {
666        file_type = "file";
667    } else if ft.is_symlink() {
668        file_type = "symlink";
669    } else {
670        #[cfg(unix)]
671        {
672            if ft.is_block_device() {
673                file_type = "block device";
674            } else if ft.is_char_device() {
675                file_type = "char device";
676            } else if ft.is_fifo() {
677                file_type = "pipe";
678            } else if ft.is_socket() {
679                file_type = "socket";
680            }
681        }
682    }
683    if use_mime_type {
684        let guess = mime_guess::from_path(display_name);
685        let mime_guess = match guess.first() {
686            Some(mime_type) => mime_type.essence_str().to_string(),
687            None => "unknown".to_string(),
688        };
689        if file_type == "file" {
690            mime_guess
691        } else {
692            file_type.to_string()
693        }
694    } else {
695        file_type.to_string()
696    }
697}
698
699/// Escape control characters in filenames so they are displayed visibly
700/// rather than being interpreted by the terminal.
701fn escape_filename_control_chars(name: &str) -> String {
702    if !name.chars().any(|c| c.is_control()) {
703        return name.to_string();
704    }
705
706    let mut buf = String::with_capacity(name.len());
707    for c in name.chars() {
708        if c.is_control() {
709            buf.extend(c.escape_unicode());
710        } else {
711            buf.push(c);
712        }
713    }
714    buf
715}
716
717#[allow(clippy::too_many_arguments)]
718pub(crate) fn dir_entry_dict(
719    filename: &std::path::Path, // absolute path
720    display_name: &str,         // file name to be displayed
721    metadata: Option<&std::fs::Metadata>,
722    span: Span,
723    long: bool,
724    du: bool,
725    signals: &Signals,
726    use_mime_type: bool,
727    full_symlink_target: bool,
728) -> Result<Value, ShellError> {
729    #[cfg(windows)]
730    if metadata.is_none() {
731        return Ok(windows_helper::dir_entry_dict_windows_fallback(
732            filename,
733            display_name,
734            span,
735            long,
736        ));
737    }
738
739    let mut record = Record::new();
740    let mut file_type = "unknown".to_string();
741
742    record.push(
743        "name",
744        Value::string(escape_filename_control_chars(display_name), span),
745    );
746
747    if let Some(md) = metadata {
748        file_type = get_file_type(md, display_name, use_mime_type);
749        record.push("type", Value::string(file_type.clone(), span));
750    } else {
751        record.push("type", Value::nothing(span));
752    }
753
754    if long && let Some(md) = metadata {
755        record.push(
756            "target",
757            if md.file_type().is_symlink() {
758                if let Ok(path_to_link) = filename.read_link() {
759                    // Actually `filename` should always have a parent because it's a symlink.
760                    // But for safety, we check `filename.parent().is_some()` first.
761                    if full_symlink_target && filename.parent().is_some() {
762                        Value::string(
763                            expand_path_with(
764                                path_to_link,
765                                filename
766                                    .parent()
767                                    .expect("already check the filename have a parent"),
768                                true,
769                            )
770                            .to_string_lossy(),
771                            span,
772                        )
773                    } else {
774                        Value::string(path_to_link.to_string_lossy(), span)
775                    }
776                } else {
777                    Value::string("Could not obtain target file's path", span)
778                }
779            } else {
780                Value::nothing(span)
781            },
782        )
783    }
784
785    if long && let Some(md) = metadata {
786        record.push("readonly", Value::bool(md.permissions().readonly(), span));
787
788        #[cfg(unix)]
789        {
790            use nu_utils::filesystem::users;
791            use std::os::unix::fs::MetadataExt;
792
793            let mode = md.permissions().mode();
794            record.push(
795                "mode",
796                Value::string(umask::Mode::from(mode).to_string(), span),
797            );
798
799            let nlinks = md.nlink();
800            record.push("num_links", Value::int(nlinks as i64, span));
801
802            let inode = md.ino();
803            record.push("inode", Value::int(inode as i64, span));
804
805            record.push(
806                "user",
807                if let Some(user) = users::get_user_by_uid(md.uid().into()) {
808                    Value::string(user.name, span)
809                } else {
810                    Value::int(md.uid().into(), span)
811                },
812            );
813
814            record.push(
815                "group",
816                if let Some(group) = users::get_group_by_gid(md.gid().into()) {
817                    Value::string(group.name, span)
818                } else {
819                    Value::int(md.gid().into(), span)
820                },
821            );
822        }
823    }
824
825    record.push(
826        "size",
827        if let Some(md) = metadata {
828            let zero_sized = file_type == "pipe"
829                || file_type == "socket"
830                || file_type == "char device"
831                || file_type == "block device";
832
833            if md.is_dir() {
834                if du {
835                    let params = DirBuilder::new(Span::new(0, 2), None, false, None, false);
836                    let dir_size = DirInfo::new(filename, &params, None, span, signals)?.get_size();
837
838                    Value::filesize(dir_size as i64, span)
839                } else {
840                    let dir_size: u64 = md.len();
841
842                    Value::filesize(dir_size as i64, span)
843                }
844            } else if md.is_file() {
845                Value::filesize(md.len() as i64, span)
846            } else if md.file_type().is_symlink() {
847                if let Ok(symlink_md) = filename.symlink_metadata() {
848                    Value::filesize(symlink_md.len() as i64, span)
849                } else {
850                    Value::nothing(span)
851                }
852            } else if zero_sized {
853                Value::filesize(0, span)
854            } else {
855                Value::nothing(span)
856            }
857        } else {
858            Value::nothing(span)
859        },
860    );
861
862    if let Some(md) = metadata {
863        if long {
864            record.push("created", {
865                let mut val = Value::nothing(span);
866                if let Ok(c) = md.created()
867                    && let Some(local) = try_convert_to_local_date_time(c)
868                {
869                    val = Value::date(local.with_timezone(local.offset()), span);
870                }
871                val
872            });
873
874            record.push("accessed", {
875                let mut val = Value::nothing(span);
876                if let Ok(a) = md.accessed()
877                    && let Some(local) = try_convert_to_local_date_time(a)
878                {
879                    val = Value::date(local.with_timezone(local.offset()), span)
880                }
881                val
882            });
883        }
884
885        record.push("modified", {
886            let mut val = Value::nothing(span);
887            if let Ok(m) = md.modified()
888                && let Some(local) = try_convert_to_local_date_time(m)
889            {
890                val = Value::date(local.with_timezone(local.offset()), span);
891            }
892            val
893        })
894    } else {
895        if long {
896            record.push("created", Value::nothing(span));
897            record.push("accessed", Value::nothing(span));
898        }
899
900        record.push("modified", Value::nothing(span));
901    }
902
903    Ok(Value::record(record, span))
904}
905
906// TODO: can we get away from local times in `ls`? internals might be cleaner if we worked in UTC
907// and left the conversion to local time to the display layer
908fn try_convert_to_local_date_time(t: SystemTime) -> Option<DateTime<Local>> {
909    // Adapted from https://github.com/chronotope/chrono/blob/v0.4.19/src/datetime.rs#L755-L767.
910    let (sec, nsec) = match t.duration_since(UNIX_EPOCH) {
911        Ok(dur) => (dur.as_secs() as i64, dur.subsec_nanos()),
912        Err(e) => {
913            // unlikely but should be handled
914            let dur = e.duration();
915            let (sec, nsec) = (dur.as_secs() as i64, dur.subsec_nanos());
916            if nsec == 0 {
917                (-sec, 0)
918            } else {
919                (-sec - 1, 1_000_000_000 - nsec)
920            }
921        }
922    };
923
924    const NEG_UNIX_EPOCH: i64 = -11644473600; // t was invalid 0, UNIX_EPOCH subtracted above.
925    if sec == NEG_UNIX_EPOCH {
926        // do not tz lookup invalid SystemTime
927        return None;
928    }
929    match Utc.timestamp_opt(sec, nsec) {
930        LocalResult::Single(t) => Some(t.with_timezone(&Local)),
931        _ => None,
932    }
933}
934
935// #[cfg(windows)] is just to make Clippy happy, remove if you ever want to use this on other platforms
936#[cfg(windows)]
937fn unix_time_to_local_date_time(secs: i64) -> Option<DateTime<Local>> {
938    match Utc.timestamp_opt(secs, 0) {
939        LocalResult::Single(t) => Some(t.with_timezone(&Local)),
940        _ => None,
941    }
942}
943
944#[cfg(windows)]
945mod windows_helper {
946    use super::*;
947
948    use nu_protocol::shell_error;
949    use std::os::windows::prelude::OsStrExt;
950    use windows::Win32::Foundation::FILETIME;
951    use windows::Win32::Storage::FileSystem::{
952        FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_READONLY, FILE_ATTRIBUTE_REPARSE_POINT, FindClose,
953        FindFirstFileW, WIN32_FIND_DATAW,
954    };
955    use windows::Win32::System::SystemServices::{
956        IO_REPARSE_TAG_MOUNT_POINT, IO_REPARSE_TAG_SYMLINK,
957    };
958
959    /// A secondary way to get file info on Windows, for when std::fs::symlink_metadata() fails.
960    /// dir_entry_dict depends on metadata, but that can't be retrieved for some Windows system files:
961    /// https://github.com/rust-lang/rust/issues/96980
962    pub fn dir_entry_dict_windows_fallback(
963        filename: &Path,
964        display_name: &str,
965        span: Span,
966        long: bool,
967    ) -> Value {
968        let mut record = Record::new();
969
970        record.push(
971            "name",
972            Value::string(escape_filename_control_chars(display_name), span),
973        );
974
975        let find_data = match find_first_file(filename, span) {
976            Ok(fd) => fd,
977            Err(e) => {
978                // Sometimes this happens when the file name is not allowed on Windows (ex: ends with a '.', pipes)
979                // For now, we just log it and give up on returning metadata columns
980                // TODO: find another way to get this data (like cmd.exe, pwsh, and MINGW bash can)
981                log::error!("ls: '{}' {}", filename.to_string_lossy(), e);
982                return Value::record(record, span);
983            }
984        };
985
986        record.push(
987            "type",
988            Value::string(get_file_type_windows_fallback(&find_data), span),
989        );
990
991        if long {
992            record.push(
993                "target",
994                if is_symlink(&find_data) {
995                    if let Ok(path_to_link) = filename.read_link() {
996                        Value::string(path_to_link.to_string_lossy(), span)
997                    } else {
998                        Value::string("Could not obtain target file's path", span)
999                    }
1000                } else {
1001                    Value::nothing(span)
1002                },
1003            );
1004
1005            record.push(
1006                "readonly",
1007                Value::bool(
1008                    find_data.dwFileAttributes & FILE_ATTRIBUTE_READONLY.0 != 0,
1009                    span,
1010                ),
1011            );
1012        }
1013
1014        let file_size = ((find_data.nFileSizeHigh as u64) << 32) | find_data.nFileSizeLow as u64;
1015        record.push("size", Value::filesize(file_size as i64, span));
1016
1017        if long {
1018            record.push("created", {
1019                let mut val = Value::nothing(span);
1020                let seconds_since_unix_epoch = unix_time_from_filetime(&find_data.ftCreationTime);
1021                if let Some(local) = unix_time_to_local_date_time(seconds_since_unix_epoch) {
1022                    val = Value::date(local.with_timezone(local.offset()), span);
1023                }
1024                val
1025            });
1026
1027            record.push("accessed", {
1028                let mut val = Value::nothing(span);
1029                let seconds_since_unix_epoch = unix_time_from_filetime(&find_data.ftLastAccessTime);
1030                if let Some(local) = unix_time_to_local_date_time(seconds_since_unix_epoch) {
1031                    val = Value::date(local.with_timezone(local.offset()), span);
1032                }
1033                val
1034            });
1035        }
1036
1037        record.push("modified", {
1038            let mut val = Value::nothing(span);
1039            let seconds_since_unix_epoch = unix_time_from_filetime(&find_data.ftLastWriteTime);
1040            if let Some(local) = unix_time_to_local_date_time(seconds_since_unix_epoch) {
1041                val = Value::date(local.with_timezone(local.offset()), span);
1042            }
1043            val
1044        });
1045
1046        Value::record(record, span)
1047    }
1048
1049    fn unix_time_from_filetime(ft: &FILETIME) -> i64 {
1050        /// January 1, 1970 as Windows file time
1051        const EPOCH_AS_FILETIME: u64 = 116444736000000000;
1052        const HUNDREDS_OF_NANOSECONDS: u64 = 10000000;
1053
1054        let time_u64 = ((ft.dwHighDateTime as u64) << 32) | (ft.dwLowDateTime as u64);
1055        if time_u64 > 0 {
1056            let rel_to_linux_epoch = time_u64.saturating_sub(EPOCH_AS_FILETIME);
1057            let seconds_since_unix_epoch = rel_to_linux_epoch / HUNDREDS_OF_NANOSECONDS;
1058            return seconds_since_unix_epoch as i64;
1059        }
1060        0
1061    }
1062
1063    // wrapper around the FindFirstFileW Win32 API
1064    fn find_first_file(filename: &Path, span: Span) -> Result<WIN32_FIND_DATAW, ShellError> {
1065        unsafe {
1066            let mut find_data = WIN32_FIND_DATAW::default();
1067            // The windows crate really needs a nicer way to do string conversions
1068            let filename_wide: Vec<u16> = filename
1069                .as_os_str()
1070                .encode_wide()
1071                .chain(std::iter::once(0))
1072                .collect();
1073
1074            match FindFirstFileW(
1075                windows::core::PCWSTR(filename_wide.as_ptr()),
1076                &mut find_data,
1077            ) {
1078                Ok(handle) => {
1079                    // Don't forget to close the Find handle
1080                    // https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-findfirstfilew#remarks
1081                    // Assumption: WIN32_FIND_DATAW is a pure data struct, so we can let our
1082                    // find_data outlive the handle.
1083                    let _ = FindClose(handle);
1084                    Ok(find_data)
1085                }
1086                Err(e) => Err(ShellError::Io(IoError::new_with_additional_context(
1087                    shell_error::io::ErrorKind::from_std(std::io::ErrorKind::Other),
1088                    span,
1089                    PathBuf::from(filename),
1090                    format!("Could not read metadata: {e}"),
1091                ))),
1092            }
1093        }
1094    }
1095
1096    fn get_file_type_windows_fallback(find_data: &WIN32_FIND_DATAW) -> String {
1097        if find_data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY.0 != 0 {
1098            return "dir".to_string();
1099        }
1100
1101        if is_symlink(find_data) {
1102            return "symlink".to_string();
1103        }
1104
1105        "file".to_string()
1106    }
1107
1108    fn is_symlink(find_data: &WIN32_FIND_DATAW) -> bool {
1109        if find_data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 {
1110            // Follow Golang's lead in treating mount points as symlinks.
1111            // https://github.com/golang/go/blob/016d7552138077741a9c3fdadc73c0179f5d3ff7/src/os/types_windows.go#L104-L105
1112            if find_data.dwReserved0 == IO_REPARSE_TAG_SYMLINK
1113                || find_data.dwReserved0 == IO_REPARSE_TAG_MOUNT_POINT
1114            {
1115                return true;
1116            }
1117        }
1118        false
1119    }
1120}
1121
1122#[allow(clippy::type_complexity)]
1123fn read_dir(
1124    f: PathBuf,
1125    span: Span,
1126    use_threads: bool,
1127    signals: Signals,
1128) -> Result<Box<dyn Iterator<Item = Result<LsEntry, ShellError>> + Send>, ShellError> {
1129    let signals_clone = signals.clone();
1130    let items = f
1131        .read_dir()
1132        .map_err(|err| IoError::new(err, span, f.clone()))?
1133        .map(move |d| {
1134            signals_clone.check(&span)?;
1135            d.map(|entry| LsEntry::from_dir_entry(&entry))
1136                .map_err(|err| IoError::new(err, span, f.clone()))
1137                .map_err(ShellError::from)
1138        });
1139    if !use_threads {
1140        let mut collected = items.collect::<Vec<_>>();
1141        signals.check(&span)?;
1142        collected.sort_by(|a, b| match (a, b) {
1143            (Ok(a), Ok(b)) => a.path.cmp(&b.path),
1144            (Ok(_), Err(_)) => Ordering::Greater,
1145            (Err(_), Ok(_)) => Ordering::Less,
1146            (Err(_), Err(_)) => Ordering::Equal,
1147        });
1148        return Ok(Box::new(collected.into_iter()));
1149    }
1150    Ok(Box::new(items))
1151}
1152
1153#[cfg(test)]
1154mod tests {
1155    use super::escape_filename_control_chars;
1156
1157    #[test]
1158    fn escape_filename_control_chars_renders_control_chars_visibly() {
1159        // Normal filenames pass through unchanged
1160        assert_eq!(escape_filename_control_chars("hello.txt"), "hello.txt");
1161        // ESC (0x1b) is escaped to its unicode representation
1162        assert_eq!(escape_filename_control_chars("hooks\x1bE"), "hooks\\u{1b}E");
1163        // NUL byte
1164        assert_eq!(
1165            escape_filename_control_chars("file\x00name"),
1166            "file\\u{0}name"
1167        );
1168        // Multiple control characters
1169        assert_eq!(
1170            escape_filename_control_chars("\x01a\x02b"),
1171            "\\u{1}a\\u{2}b"
1172        );
1173    }
1174}