diff --git a/dissect/target/tools/fs.py b/dissect/target/tools/fs.py index 625d486233..2ca728434b 100644 --- a/dissect/target/tools/fs.py +++ b/dissect/target/tools/fs.py @@ -38,15 +38,17 @@ def ls(t: Target, path: TargetPath, args: argparse.Namespace) -> None: use_colors = sys.stdout.buffer.isatty() print_ls( - path, - 0, - sys.stdout, - args.l, - args.human_readable, - args.recursive, - args.use_ctime, - args.use_atime, - use_colors, + path=path, + depth=0, + stdout=sys.stdout, + long_listing=args.l, + human_readable=args.human_readable, + recursive=args.recursive, + use_ctime=args.use_ctime, + use_atime=args.use_atime, + sort_by_time=args.sort_by_time, + reverse_sort=args.reverse_sort, + color=use_colors, ) @@ -124,6 +126,8 @@ def main() -> int: "-c", action="store_true", dest="use_ctime", help="show time when file status was last changed" ) parser_ls.add_argument("-u", action="store_true", dest="use_atime", help="show time of last access") + parser_ls.add_argument("-t", action="store_true", dest="sort_by_time", help="sort by time (newest first)") + parser_ls.add_argument("-r", action="store_true", dest="reverse_sort", help="reverse the sort order") parser_ls.set_defaults(handler=ls) parser_cat = subparsers.add_parser("cat", help="dump file contents", parents=[baseparser]) diff --git a/dissect/target/tools/shell.py b/dissect/target/tools/shell.py index 6b029e55a8..d52e20f269 100644 --- a/dissect/target/tools/shell.py +++ b/dissect/target/tools/shell.py @@ -652,12 +652,12 @@ def completedefault(self, text: str, line: str, begidx: int, endidx: int) -> lis textlower = text.lower() suggestions = [] - for fpath, fname in ls_scandir(path): - if not fname.lower().startswith(textlower): + for entry in ls_scandir(path): + if not entry.name.lower().startswith(textlower): continue # Add a trailing slash to directories, to allow for easier traversal of the filesystem - suggestion = f"{fname}/" if fpath.is_dir() else fname + suggestion = f"{entry.name}/" if entry.path.is_dir() else entry.name suggestions.append(suggestion) return suggestions @@ -793,6 +793,8 @@ def do_reload(self, line: str) -> bool: @arg("-R", "--recursive", action="store_true", help="recursively list subdirectories encountered") @arg("-c", action="store_true", dest="use_ctime", help="show time when file status was last changed") @arg("-u", action="store_true", dest="use_atime", help="show time of last access") + @arg("-t", action="store_true", dest="sort_by_time", help="sort by time, newest first") + @arg("-r", action="store_true", dest="reverse_sort", help="reverse sort order") @alias("l") @alias("dir") def cmd_ls(self, args: argparse.Namespace, stdout: TextIO) -> bool: @@ -819,14 +821,16 @@ def cmd_ls(self, args: argparse.Namespace, stdout: TextIO) -> bool: use_color = False print_ls( - path, - 0, - stdout, - args.l, - args.human_readable, - args.recursive, - args.use_ctime, - args.use_atime, + path=path, + depth=0, + stdout=stdout, + long_listing=args.l, + human_readable=args.human_readable, + recursive=args.recursive, + use_ctime=args.use_ctime, + use_atime=args.use_atime, + sort_by_time=args.sort_by_time, + reverse_sort=args.reverse_sort, color=use_color, ) return False diff --git a/dissect/target/tools/utils/fs.py b/dissect/target/tools/utils/fs.py index 68bb32ca73..d4715f3438 100644 --- a/dissect/target/tools/utils/fs.py +++ b/dissect/target/tools/utils/fs.py @@ -3,7 +3,7 @@ import os import stat from datetime import datetime, timezone -from typing import TYPE_CHECKING, TextIO +from typing import TYPE_CHECKING, NamedTuple, TextIO from dissect.target.exceptions import FileNotFoundError from dissect.target.filesystem import FilesystemEntry, LayerFilesystemEntry @@ -14,6 +14,14 @@ from dissect.target.helpers.fsutil import TargetPath + +class ScanDirEntry(NamedTuple): + path: TargetPath + name: str + entry: FilesystemEntry | None = None + lstat: fsutil.stat_result | None = None + + # ['mode', 'addr', 'dev', 'nlink', 'uid', 'gid', 'size', 'atime', 'mtime', 'ctime'] STAT_TEMPLATE = """ File: {path} {symlink} Size: {size} Blocks: {blocks} IO Block: {blksize} {filetype} @@ -106,7 +114,7 @@ def print_extensive_file_stat_listing( print(f"?????????? ? ?{regular_spaces}?{hr_spaces}????-??-??T??:??:??.??????+??:?? {name}", file=stdout) -def ls_scandir(path: fsutil.TargetPath, color: bool = False) -> list[tuple[fsutil.TargetPath, str]]: +def ls_scandir(path: fsutil.TargetPath, *, color: bool = False) -> list[ScanDirEntry]: """List a directory for the given path.""" result = [] if not path.exists() or not path.is_dir(): @@ -122,7 +130,14 @@ def ls_scandir(path: fsutil.TargetPath, color: bool = False) -> list[tuple[fsuti elif file_.is_file(): file_type = "fi" - result.append((file_, fmt_ls_colors(file_type, file_.name) if color else file_.name)) + result.append( + ScanDirEntry( + file_, + fmt_ls_colors(file_type, file_.name) if color else file_.name, + file_.get(), + file_.lstat(), + ) + ) # If we happen to scan an NTFS filesystem see if any of the # entries has an alternative data stream and also list them. @@ -132,14 +147,22 @@ def ls_scandir(path: fsutil.TargetPath, color: bool = False) -> list[tuple[fsuti for data_stream in attrs.DATA: if data_stream.name != "": name = f"{file_.name}:{data_stream.name}" - result.append((file_, fmt_ls_colors(file_type, name) if color else name)) + result.append( + ScanDirEntry( + file_, + fmt_ls_colors(file_type, name) if color else name, + file_.get(), + file_.lstat(), + ) + ) - result.sort(key=lambda e: e[0].name) + result.sort(key=lambda e: e.name) return result def print_ls( + *, path: fsutil.TargetPath, depth: int, stdout: TextIO, @@ -148,46 +171,76 @@ def print_ls( recursive: bool = False, use_ctime: bool = False, use_atime: bool = False, + sort_by_time: bool = False, + reverse_sort: bool = False, color: bool = True, ) -> None: """Print ls output.""" subdirs = [] if path.is_dir(): - contents = ls_scandir(path, color) - elif path.is_file(): - contents = [(path, path.name)] + contents = ls_scandir(path, color=color) + else: + contents = [ScanDirEntry(path, path.name, path.get(), path.lstat())] + + if sort_by_time: + + def sort_key(e: ScanDirEntry) -> tuple[float, str]: + try: + show_time = e.lstat.st_mtime + if use_ctime: + show_time = e.lstat.st_ctime + elif use_atime: + show_time = e.lstat.st_atime + except FileNotFoundError: + show_time = 0 + return show_time, e.name + + contents.sort(key=sort_key, reverse=True) + + if reverse_sort: + contents.reverse() if depth > 0: print(f"\n{path!s}:", file=stdout) if not long_listing: - for target_path, name in contents: - print(name, file=stdout) - if not target_path.is_symlink() and target_path.is_dir(): - subdirs.append(target_path) + for entry in contents: + print(entry.name, file=stdout) + if not entry.path.is_symlink() and entry.path.is_dir(): + subdirs.append(entry.path) else: if len(contents) > 1: print(f"total {len(contents)}", file=stdout) - for target_path, name in contents: + for entry in contents: try: - entry = target_path.get() - entry_stat = entry.lstat() - show_time = entry_stat.st_mtime + show_time = entry.lstat.st_mtime if use_ctime: - show_time = entry_stat.st_ctime + show_time = entry.lstat.st_ctime elif use_atime: - show_time = entry_stat.st_atime + show_time = entry.lstat.st_atime except FileNotFoundError: entry = None show_time = None - print_extensive_file_stat_listing(stdout, name, entry, show_time, human_readable) - if target_path.is_dir(): - subdirs.append(target_path) + print_extensive_file_stat_listing(stdout, entry.name, entry.entry, show_time, human_readable) + if entry.path.is_dir(): + subdirs.append(entry.path) if recursive and subdirs: for subdir in subdirs: - print_ls(subdir, depth + 1, stdout, long_listing, human_readable, recursive, use_ctime, use_atime, color) + print_ls( + path=subdir, + depth=depth + 1, + stdout=stdout, + long_listing=long_listing, + human_readable=human_readable, + recursive=recursive, + use_ctime=use_ctime, + use_atime=use_atime, + reverse_sort=reverse_sort, + sort_by_time=sort_by_time, + color=color, + ) def print_stat(path: fsutil.TargetPath, stdout: TextIO, dereference: bool = False) -> None: diff --git a/tests/tools/test_shell.py b/tests/tools/test_shell.py index 3df670478a..a2abfc8988 100644 --- a/tests/tools/test_shell.py +++ b/tests/tools/test_shell.py @@ -137,12 +137,12 @@ def test_targetcli_autocomplete(target_bare: Target, monkeypatch: pytest.MonkeyP subfile_name = "subfile" subpath_mismatch = "mismatch" - def dummy_scandir(path: TargetPath) -> list[tuple[TargetPath | None, str]]: + def dummy_scandir(path: TargetPath) -> list[fs.ScanDirEntry]: assert str(path) == base_path return [ - (mock_subfolder, subfolder_name), - (mock_subfile, subfile_name), - (None, subpath_mismatch), + fs.ScanDirEntry(mock_subfolder, subfolder_name), + fs.ScanDirEntry(mock_subfile, subfile_name), + fs.ScanDirEntry(None, subpath_mismatch), ] monkeypatch.setattr("dissect.target.tools.shell.ls_scandir", dummy_scandir) diff --git a/tox.ini b/tox.ini index 093c597bba..7186b1f1b9 100644 --- a/tox.ini +++ b/tox.ini @@ -44,8 +44,8 @@ commands = package = skip dependency_groups = lint commands = - ruff check --fix dissect tests ruff format dissect tests + ruff check --fix dissect tests [testenv:lint] package = skip