dissect.target 3.19.dev40__py3-none-any.whl → 3.19.dev41__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
+ from __future__ import annotations
2
+
1
3
  import argparse
2
4
  import cmd
3
5
  import contextlib
4
- import datetime
5
6
  import fnmatch
6
7
  import io
7
8
  import itertools
@@ -13,27 +14,33 @@ import random
13
14
  import re
14
15
  import shlex
15
16
  import shutil
16
- import stat
17
17
  import subprocess
18
18
  import sys
19
19
  from contextlib import contextmanager
20
- from typing import Any, BinaryIO, Callable, Iterator, Optional, TextIO, Union
20
+ from datetime import datetime, timedelta, timezone
21
+ from typing import Any, BinaryIO, Callable, Iterator, TextIO
21
22
 
22
23
  from dissect.cstruct import hexdump
23
24
  from flow.record import RecordOutput
24
25
 
25
26
  from dissect.target.exceptions import (
26
- FileNotFoundError,
27
27
  PluginError,
28
28
  RegistryError,
29
29
  RegistryKeyNotFoundError,
30
30
  RegistryValueNotFoundError,
31
31
  TargetError,
32
32
  )
33
- from dissect.target.filesystem import FilesystemEntry, LayerFilesystemEntry
33
+ from dissect.target.filesystem import FilesystemEntry
34
34
  from dissect.target.helpers import cyber, fsutil, regutil
35
- from dissect.target.plugin import PluginFunction, arg
35
+ from dissect.target.plugin import PluginFunction, alias, arg, clone_alias
36
36
  from dissect.target.target import Target
37
+ from dissect.target.tools.fsutils import (
38
+ fmt_ls_colors,
39
+ ls_scandir,
40
+ print_ls,
41
+ print_stat,
42
+ print_xattr,
43
+ )
37
44
  from dissect.target.tools.info import print_target_info
38
45
  from dissect.target.tools.utils import (
39
46
  args_to_uri,
@@ -52,47 +59,23 @@ logging.raiseExceptions = False
52
59
  try:
53
60
  import readline
54
61
 
55
- # remove `-` as an autocomplete delimeter on Linux
62
+ # remove `-`, `$` and `{` as an autocomplete delimeter on Linux
56
63
  # https://stackoverflow.com/questions/27288340/python-cmd-on-linux-does-not-autocomplete-special-characters-or-symbols
57
- readline.set_completer_delims(readline.get_completer_delims().replace("-", "").replace("$", ""))
64
+ readline.set_completer_delims(readline.get_completer_delims().replace("-", "").replace("$", "").replace("{", ""))
65
+
66
+ # Fix autocomplete on macOS
67
+ # https://stackoverflow.com/a/7116997
68
+ if "libedit" in readline.__doc__:
69
+ readline.parse_and_bind("bind ^I rl_complete")
70
+ else:
71
+ readline.parse_and_bind("tab: complete")
58
72
  except ImportError:
59
73
  # Readline is not available on Windows
60
74
  log.warning("Readline module is not available")
61
75
  readline = None
62
76
 
63
- # ['mode', 'addr', 'dev', 'nlink', 'uid', 'gid', 'size', 'atime', 'mtime', 'ctime']
64
- STAT_TEMPLATE = """ File: {path} {symlink}
65
- Size: {size} {filetype}
66
- Inode: {inode} Links: {nlink}
67
- Access: ({modeord}/{modestr}) Uid: ( {uid} ) Gid: ( {gid} )
68
- Access: {atime}
69
- Modify: {mtime}
70
- Change: {ctime}"""
71
-
72
- FALLBACK_LS_COLORS = "rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:mi=00:su=37;41:sg=30;43:ca=30;41:tw=30;42:ow=34;42:st=37;44:ex=01;32" # noqa: E501
73
-
74
-
75
- def prepare_ls_colors() -> dict[str, str]:
76
- """Parse the LS_COLORS environment variable so we can use it later."""
77
- d = {}
78
- ls_colors = os.environ.get("LS_COLORS", FALLBACK_LS_COLORS)
79
- for line in ls_colors.split(":"):
80
- if not line:
81
- continue
82
-
83
- ft, _, value = line.partition("=")
84
- if ft.startswith("*"):
85
- ft = ft[1:]
86
-
87
- d[ft] = f"\x1b[{value}m{{}}\x1b[0m"
88
77
 
89
- return d
90
-
91
-
92
- LS_COLORS = prepare_ls_colors()
93
-
94
-
95
- class TargetCmd(cmd.Cmd):
78
+ class ExtendedCmd(cmd.Cmd):
96
79
  """Subclassed cmd.Cmd to provide some additional features.
97
80
 
98
81
  Add new simple commands by implementing:
@@ -112,49 +95,25 @@ class TargetCmd(cmd.Cmd):
112
95
 
113
96
  CMD_PREFIX = "cmd_"
114
97
 
115
- DEFAULT_HISTFILE = "~/.dissect_history"
116
- DEFAULT_HISTFILESIZE = 10_000
117
- DEFAULT_HISTDIR = None
118
- DEFAULT_HISTDIRFMT = ".dissect_history_{uid}_{target}"
119
-
120
- def __init__(self, target: Target):
98
+ def __init__(self, cyber: bool = False):
121
99
  cmd.Cmd.__init__(self)
122
- self.target = target
123
100
  self.debug = False
101
+ self.cyber = cyber
124
102
  self.identchars += "."
125
103
 
126
- self.histfilesize = getattr(target._config, "HISTFILESIZE", self.DEFAULT_HISTFILESIZE)
127
- self.histdir = getattr(target._config, "HISTDIR", self.DEFAULT_HISTDIR)
128
-
129
- if self.histdir:
130
- self.histdirfmt = getattr(target._config, "HISTDIRFMT", self.DEFAULT_HISTDIRFMT)
131
- self.histfile = pathlib.Path(self.histdir).resolve() / pathlib.Path(
132
- self.histdirfmt.format(uid=os.getuid(), target=target.name)
133
- )
134
- else:
135
- self.histfile = pathlib.Path(getattr(target._config, "HISTFILE", self.DEFAULT_HISTFILE)).expanduser()
136
-
137
- def preloop(self) -> None:
138
- if readline and self.histfile.exists():
139
- try:
140
- readline.read_history_file(self.histfile)
141
- except Exception as e:
142
- log.debug("Error reading history file: %s", e)
143
-
144
- def postloop(self) -> None:
145
- if readline:
146
- readline.set_history_length(self.histfilesize)
147
- try:
148
- readline.write_history_file(self.histfile)
149
- except Exception as e:
150
- log.debug("Error writing history file: %s", e)
104
+ self.register_aliases()
151
105
 
152
106
  def __getattr__(self, attr: str) -> Any:
153
107
  if attr.startswith("help_"):
154
108
  _, _, command = attr.partition("_")
109
+
110
+ def print_help(command: str, func: Callable) -> None:
111
+ parser = generate_argparse_for_bound_method(func, usage_tmpl=f"{command} {{usage}}")
112
+ parser.print_help()
113
+
155
114
  try:
156
115
  func = getattr(self, self.CMD_PREFIX + command)
157
- return lambda: print(func.__doc__)
116
+ return lambda: print_help(command, func)
158
117
  except AttributeError:
159
118
  pass
160
119
 
@@ -164,6 +123,16 @@ class TargetCmd(cmd.Cmd):
164
123
  def check_compatible(target: Target) -> bool:
165
124
  return True
166
125
 
126
+ def register_aliases(self) -> None:
127
+ for name in self.get_names():
128
+ if name.startswith(self.CMD_PREFIX):
129
+ func = getattr(self.__class__, name)
130
+ for alias_name in getattr(func, "__aliases__", []):
131
+ if not alias_name.startswith(self.CMD_PREFIX):
132
+ alias_name = self.CMD_PREFIX + alias_name
133
+
134
+ clone_alias(self.__class__, func, alias_name)
135
+
167
136
  def get_names(self) -> list[str]:
168
137
  names = cmd.Cmd.get_names(self)
169
138
 
@@ -175,44 +144,49 @@ class TargetCmd(cmd.Cmd):
175
144
 
176
145
  return names
177
146
 
178
- def default(self, line: str) -> Optional[bool]:
147
+ def _handle_command(self, line: str) -> bool | None:
148
+ """Check whether custom handling of the cmd can be performed and if so, do it.
149
+
150
+ If a custom handling of the cmd was performed, return the result (a boolean indicating whether the shell should
151
+ exit). If not, return None. Can be overridden by subclasses to perform further / other 'custom command' checks.
152
+ """
179
153
  if line == "EOF":
180
154
  return True
181
155
 
182
- # Override default command execution to first attempt complex
183
- # command execution, and then target plugin command execution
156
+ # Override default command execution to first attempt complex command execution
184
157
  command, command_args_str, line = self.parseline(line)
185
158
 
186
- try:
159
+ if hasattr(self, self.CMD_PREFIX + command):
187
160
  return self._exec_command(command, command_args_str)
188
- except AttributeError:
189
- pass
190
161
 
191
- if plugins := list(find_and_filter_plugins(self.target, command, [])):
192
- return self._exec_target(plugins, command_args_str)
162
+ # Return None if no custom command was found to be run
163
+ return None
193
164
 
194
- return cmd.Cmd.default(self, line)
165
+ def default(self, line: str) -> bool:
166
+ if (should_exit := self._handle_command(line)) is not None:
167
+ return should_exit
168
+
169
+ # Fallback to default
170
+ cmd.Cmd.default(self, line)
171
+ return False
195
172
 
196
173
  def emptyline(self) -> None:
197
174
  """This function forces Python's cmd.Cmd module to behave like a regular shell.
198
175
 
199
176
  When entering an empty command, the cmd module will by default repeat the previous command.
200
177
  By defining an empty ``emptyline`` function we make sure no command is executed instead.
201
- See https://stackoverflow.com/a/16479030
178
+ Resources:
179
+ - https://stackoverflow.com/a/16479030
180
+ - https://github.com/python/cpython/blob/3.12/Lib/cmd.py#L10
202
181
  """
203
182
  pass
204
183
 
205
- def _exec(
206
- self, func: Callable[[list[str], TextIO], bool], command_args_str: str, no_cyber: bool = False
207
- ) -> Optional[bool]:
184
+ def _exec(self, func: Callable[[list[str], TextIO], bool], command_args_str: str, no_cyber: bool = False) -> bool:
208
185
  """Command execution helper that chains initial command and piped subprocesses (if any) together."""
209
186
 
210
187
  argparts = []
211
188
  if command_args_str is not None:
212
- lexer = shlex.shlex(command_args_str, posix=True, punctuation_chars=True)
213
- lexer.wordchars += "$"
214
- lexer.whitespace_split = True
215
- argparts = list(lexer)
189
+ argparts = arg_str_to_arg_list(command_args_str)
216
190
 
217
191
  if "|" in argparts:
218
192
  pipeidx = argparts.index("|")
@@ -223,15 +197,16 @@ class TargetCmd(cmd.Cmd):
223
197
  except OSError as e:
224
198
  # in case of a failure in a subprocess
225
199
  print(e)
200
+ return False
226
201
  else:
227
202
  ctx = contextlib.nullcontext()
228
- if self.target.props.get("cyber") and not no_cyber:
203
+ if self.cyber and not no_cyber:
229
204
  ctx = cyber.cyber(color=None, run_at_end=True)
230
205
 
231
206
  with ctx:
232
207
  return func(argparts, sys.stdout)
233
208
 
234
- def _exec_command(self, command: str, command_args_str: str) -> Optional[bool]:
209
+ def _exec_command(self, command: str, command_args_str: str) -> bool:
235
210
  """Command execution helper for ``cmd_`` commands."""
236
211
  cmdfunc = getattr(self, self.CMD_PREFIX + command)
237
212
  argparser = generate_argparse_for_bound_method(cmdfunc, usage_tmpl=f"{command} {{usage}}")
@@ -240,21 +215,122 @@ class TargetCmd(cmd.Cmd):
240
215
  try:
241
216
  args = argparser.parse_args(argparts)
242
217
  except SystemExit:
243
- return
218
+ return False
244
219
  return cmdfunc(args, stdout)
245
220
 
246
221
  # These commands enter a subshell, which doesn't work well with cyber
247
222
  no_cyber = cmdfunc.__func__ in (TargetCli.cmd_registry, TargetCli.cmd_enter)
248
223
  return self._exec(_exec_, command_args_str, no_cyber)
249
224
 
250
- def _exec_target(self, funcs: list[PluginFunction], command_args_str: str) -> Optional[bool]:
225
+ def do_man(self, line: str) -> bool:
226
+ """alias for help"""
227
+ return self.do_help(line)
228
+
229
+ def complete_man(self, *args) -> list[str]:
230
+ return cmd.Cmd.complete_help(self, *args)
231
+
232
+ def do_clear(self, line: str) -> bool:
233
+ """clear the terminal screen"""
234
+ os.system("cls||clear")
235
+ return False
236
+
237
+ def do_cls(self, line: str) -> bool:
238
+ """alias for clear"""
239
+ return self.do_clear(line)
240
+
241
+ def do_exit(self, line: str) -> bool:
242
+ """exit shell"""
243
+ return True
244
+
245
+ def do_cyber(self, line: str) -> bool:
246
+ """cyber"""
247
+ self.cyber = not self.cyber
248
+ word, color = {False: ("D I S E N", cyber.Color.RED), True: ("E N", cyber.Color.YELLOW)}[self.cyber]
249
+ with cyber.cyber(color=color):
250
+ print(f"C Y B E R - M O D E - {word} G A G E D")
251
+ return False
252
+
253
+ def do_debug(self, line: str) -> bool:
254
+ """toggle debug mode"""
255
+ self.debug = not self.debug
256
+ if self.debug:
257
+ print("Debug mode on")
258
+ else:
259
+ print("Debug mode off")
260
+ return False
261
+
262
+
263
+ class TargetCmd(ExtendedCmd):
264
+ DEFAULT_HISTFILE = "~/.dissect_history"
265
+ DEFAULT_HISTFILESIZE = 10_000
266
+ DEFAULT_HISTDIR = None
267
+ DEFAULT_HISTDIRFMT = ".dissect_history_{uid}_{target}"
268
+
269
+ def __init__(self, target: Target):
270
+ self.target = target
271
+
272
+ # history file
273
+ self.histfilesize = getattr(target._config, "HISTFILESIZE", self.DEFAULT_HISTFILESIZE)
274
+ self.histdir = getattr(target._config, "HISTDIR", self.DEFAULT_HISTDIR)
275
+
276
+ if self.histdir:
277
+ self.histdirfmt = getattr(target._config, "HISTDIRFMT", self.DEFAULT_HISTDIRFMT)
278
+ self.histfile = pathlib.Path(self.histdir).resolve() / pathlib.Path(
279
+ self.histdirfmt.format(uid=os.getuid(), target=target.name)
280
+ )
281
+ else:
282
+ self.histfile = pathlib.Path(getattr(target._config, "HISTFILE", self.DEFAULT_HISTFILE)).expanduser()
283
+
284
+ # prompt format
285
+ if ps1 := getattr(target._config, "PS1", None):
286
+ if "{cwd}" in ps1 and "{base}" in ps1:
287
+ self.prompt_ps1 = ps1
288
+
289
+ elif getattr(target._config, "NO_COLOR", None) or os.getenv("NO_COLOR"):
290
+ self.prompt_ps1 = "{base}:{cwd}$ "
291
+
292
+ else:
293
+ self.prompt_ps1 = "\x1b[1;32m{base}\x1b[0m:\x1b[1;34m{cwd}\x1b[0m$ "
294
+
295
+ super().__init__(self.target.props.get("cyber"))
296
+
297
+ def preloop(self) -> None:
298
+ if readline and self.histfile.exists():
299
+ try:
300
+ readline.read_history_file(self.histfile)
301
+ except Exception as e:
302
+ log.debug("Error reading history file: %s", e)
303
+
304
+ def postloop(self) -> None:
305
+ if readline:
306
+ readline.set_history_length(self.histfilesize)
307
+ try:
308
+ readline.write_history_file(self.histfile)
309
+ except Exception as e:
310
+ log.debug("Error writing history file: %s", e)
311
+
312
+ def _handle_command(self, line: str) -> bool | None:
313
+ if (should_exit := super()._handle_command(line)) is not None:
314
+ return should_exit
315
+
316
+ # The parent class has already attempted complex command execution, we now attempt target plugin command
317
+ # execution
318
+ command, command_args_str, line = self.parseline(line)
319
+
320
+ if plugins := list(find_and_filter_plugins(self.target, command, [])):
321
+ return self._exec_target(plugins, command_args_str)
322
+
323
+ # We didn't execute a function on the target
324
+ return None
325
+
326
+ def _exec_target(self, funcs: list[PluginFunction], command_args_str: str) -> bool:
251
327
  """Command exection helper for target plugins."""
252
328
 
253
- def _exec_(argparts: list[str], stdout: TextIO) -> Optional[bool]:
329
+ def _exec_(argparts: list[str], stdout: TextIO) -> None:
254
330
  try:
255
331
  output, value, _ = execute_function_on_target(self.target, func, argparts)
256
332
  except SystemExit:
257
- return False
333
+ return
258
334
 
259
335
  if output == "record":
260
336
  # if the command results are piped to another process,
@@ -276,45 +352,21 @@ class TargetCmd(cmd.Cmd):
276
352
  else:
277
353
  print(value, file=stdout)
278
354
 
279
- result = None
280
355
  for func in funcs:
281
356
  try:
282
- result = self._exec(_exec_, command_args_str)
357
+ self._exec(_exec_, command_args_str)
283
358
  except PluginError as err:
284
359
  if self.debug:
285
360
  raise err
286
361
  self.target.log.error(err)
287
362
 
288
- return result
363
+ # Keep the shell open
364
+ return False
289
365
 
290
- def do_python(self, line: str) -> Optional[bool]:
366
+ def do_python(self, line: str) -> bool:
291
367
  """drop into a Python shell"""
292
368
  python_shell([self.target])
293
-
294
- def do_clear(self, line: str) -> Optional[bool]:
295
- """clear the terminal screen"""
296
- os.system("cls||clear")
297
-
298
- def do_cyber(self, line: str) -> Optional[bool]:
299
- """cyber"""
300
- self.target.props["cyber"] = not bool(self.target.props.get("cyber"))
301
- word, color = {False: ("D I S E N", cyber.Color.RED), True: ("E N", cyber.Color.YELLOW)}[
302
- self.target.props["cyber"]
303
- ]
304
- with cyber.cyber(color=color):
305
- print(f"C Y B E R - M O D E - {word} G A G E D")
306
-
307
- def do_exit(self, line: str) -> Optional[bool]:
308
- """exit shell"""
309
- return True
310
-
311
- def do_debug(self, line: str) -> Optional[bool]:
312
- """toggle debug mode"""
313
- self.debug = not self.debug
314
- if self.debug:
315
- print("Debug mode on")
316
- else:
317
- print("Debug mode off")
369
+ return False
318
370
 
319
371
 
320
372
  class TargetHubCli(cmd.Cmd):
@@ -337,24 +389,26 @@ class TargetHubCli(cmd.Cmd):
337
389
 
338
390
  self._clicache = {}
339
391
 
340
- def default(self, line: str) -> Optional[bool]:
392
+ def default(self, line: str) -> bool:
341
393
  if line == "EOF":
342
394
  return True
343
395
 
344
- return cmd.Cmd.default(self, line)
396
+ cmd.Cmd.default(self, line)
397
+ return False
345
398
 
346
399
  def emptyline(self) -> None:
347
400
  pass
348
401
 
349
- def do_exit(self, line: str) -> Optional[bool]:
402
+ def do_exit(self, line: str) -> bool:
350
403
  """exit shell"""
351
404
  return True
352
405
 
353
- def do_list(self, line: str) -> Optional[bool]:
406
+ def do_list(self, line: str) -> bool:
354
407
  """list the loaded targets"""
355
408
  print("\n".join([f"{i:2d}: {e}" for i, e in enumerate(self._names)]))
409
+ return False
356
410
 
357
- def do_enter(self, line: str) -> Optional[bool]:
411
+ def do_enter(self, line: str) -> bool:
358
412
  """enter a target by number or name"""
359
413
 
360
414
  if line.isdigit():
@@ -364,24 +418,25 @@ class TargetHubCli(cmd.Cmd):
364
418
  idx = self._names_lower.index(line.lower())
365
419
  except ValueError:
366
420
  print("Unknown name")
367
- return
421
+ return False
368
422
 
369
423
  if idx >= len(self.targets):
370
424
  print("Unknown target")
371
- return
425
+ return False
372
426
 
373
427
  try:
374
428
  cli = self._clicache[idx]
375
429
  except KeyError:
376
430
  target = self.targets[idx]
377
431
  if not self._targetcli.check_compatible(target):
378
- return
432
+ return False
379
433
 
380
434
  cli = self._targetcli(self.targets[idx])
381
435
  self._clicache[idx] = cli
382
436
 
383
437
  print(f"Entering {idx}: {self._names[idx]}")
384
438
  run_cli(cli)
439
+ return False
385
440
 
386
441
  def complete_enter(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
387
442
  if not text:
@@ -395,32 +450,41 @@ class TargetHubCli(cmd.Cmd):
395
450
 
396
451
  return compl
397
452
 
398
- def do_python(self, line: str) -> Optional[bool]:
453
+ def do_python(self, line: str) -> bool:
399
454
  """drop into a Python shell"""
400
455
  python_shell(self.targets)
456
+ return False
401
457
 
402
458
 
403
459
  class TargetCli(TargetCmd):
404
460
  """CLI for interacting with a target and browsing the filesystem."""
405
461
 
406
462
  def __init__(self, target: Target):
463
+ self.prompt_base = _target_name(target)
464
+
407
465
  TargetCmd.__init__(self, target)
408
- self.prompt_base = target.name
409
- self._clicache = {}
410
466
 
467
+ self._clicache = {}
411
468
  self.cwd = None
412
469
  self.chdir("/")
413
470
 
414
471
  @property
415
472
  def prompt(self) -> str:
416
- return f"{self.prompt_base} {self.cwd}> "
473
+ return self.prompt_ps1.format(base=self.prompt_base, cwd=self.cwd)
417
474
 
418
- def completedefault(self, text: str, line: str, begidx: int, endidx: int):
419
- path = line[:begidx].rsplit(" ")[-1]
475
+ def completedefault(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
476
+ path = self.resolve_path(line[:begidx].rsplit(" ")[-1])
420
477
  textlower = text.lower()
421
478
 
422
- r = [fname for _, fname in self.scandir(path) if fname.lower().startswith(textlower)]
423
- return r
479
+ suggestions = []
480
+ for fpath, fname in ls_scandir(path):
481
+ if not fname.lower().startswith(textlower):
482
+ continue
483
+
484
+ # Add a trailing slash to directories, to allow for easier traversal of the filesystem
485
+ suggestion = f"{fname}/" if fpath.is_dir() else fname
486
+ suggestions.append(suggestion)
487
+ return suggestions
424
488
 
425
489
  def resolve_path(self, path: str) -> fsutil.TargetPath:
426
490
  if not path:
@@ -448,7 +512,7 @@ class TargetCli(TargetCmd):
448
512
  # component
449
513
  print(err)
450
514
 
451
- def check_file(self, path: str) -> Optional[fsutil.TargetPath]:
515
+ def check_file(self, path: str) -> fsutil.TargetPath | None:
452
516
  path = self.resolve_path(path)
453
517
  if not path.exists():
454
518
  print(f"{path}: No such file")
@@ -464,7 +528,7 @@ class TargetCli(TargetCmd):
464
528
 
465
529
  return path
466
530
 
467
- def check_dir(self, path: str) -> Optional[fsutil.TargetPath]:
531
+ def check_dir(self, path: str) -> fsutil.TargetPath | None:
468
532
  path = self.resolve_path(path)
469
533
  if not path.exists():
470
534
  print(f"{path}: No such directory")
@@ -480,70 +544,51 @@ class TargetCli(TargetCmd):
480
544
 
481
545
  return path
482
546
 
547
+ def check_path(self, path: str) -> fsutil.TargetPath | None:
548
+ path = self.resolve_path(path)
549
+ if not path.exists():
550
+ print(f"{path}: No such file or directory")
551
+ return
552
+
553
+ return path
554
+
483
555
  def chdir(self, path: str) -> None:
484
556
  """Change directory to the given path."""
485
557
  if path := self.check_dir(path):
486
558
  self.cwd = path
487
559
 
488
- def scandir(self, path: str, color: bool = False) -> list[tuple[fsutil.TargetPath, str]]:
489
- """List a directory for the given path."""
490
- path = self.resolve_path(path)
491
- result = []
492
-
493
- if path.exists() and path.is_dir():
494
- for file_ in path.iterdir():
495
- file_type = None
496
- if color:
497
- if file_.is_symlink():
498
- file_type = "ln"
499
- elif file_.is_dir():
500
- file_type = "di"
501
- elif file_.is_file():
502
- file_type = "fi"
503
-
504
- result.append((file_, fmt_ls_colors(file_type, file_.name) if color else file_.name))
505
-
506
- # If we happen to scan an NTFS filesystem see if any of the
507
- # entries has an alternative data stream and also list them.
508
- entry = file_.get()
509
- if isinstance(entry, LayerFilesystemEntry):
510
- if entry.entries.fs.__type__ == "ntfs":
511
- attrs = entry.lattr()
512
- for data_stream in attrs.DATA:
513
- if data_stream.name != "":
514
- name = f"{file_.name}:{data_stream.name}"
515
- result.append((file_, fmt_ls_colors(file_type, name) if color else name))
516
-
517
- result.sort(key=lambda e: e[0].name)
518
-
519
- return result
520
-
521
- def do_cd(self, line: str) -> Optional[bool]:
560
+ def do_cd(self, line: str) -> bool:
522
561
  """change directory"""
523
562
  self.chdir(line)
563
+ return False
524
564
 
525
- def do_pwd(self, line: str) -> Optional[bool]:
565
+ def do_pwd(self, line: str) -> bool:
526
566
  """print current directory"""
527
567
  print(self.cwd)
568
+ return False
528
569
 
529
- def do_disks(self, line: str) -> Optional[bool]:
570
+ def do_disks(self, line: str) -> bool:
530
571
  """print target disks"""
531
572
  for d in self.target.disks:
532
573
  print(str(d))
574
+ return False
533
575
 
534
- def do_volumes(self, line: str) -> Optional[bool]:
576
+ def do_volumes(self, line: str) -> bool:
535
577
  """print target volumes"""
536
578
  for v in self.target.volumes:
537
579
  print(str(v))
580
+ return False
538
581
 
539
- def do_filesystems(self, line: str) -> Optional[bool]:
582
+ def do_filesystems(self, line: str) -> bool:
540
583
  """print target filesystems"""
541
584
  for fs in self.target.filesystems:
542
585
  print(str(fs))
586
+ return False
543
587
 
544
- def do_info(self, line: str) -> Optional[bool]:
588
+ def do_info(self, line: str) -> bool:
545
589
  """print target information"""
546
- return print_target_info(self.target)
590
+ print_target_info(self.target)
591
+ return False
547
592
 
548
593
  @arg("path", nargs="?")
549
594
  @arg("-l", action="store_true")
@@ -552,129 +597,137 @@ class TargetCli(TargetCmd):
552
597
  @arg("-R", "--recursive", action="store_true", help="recursively list subdirectories encountered")
553
598
  @arg("-c", action="store_true", dest="use_ctime", help="show time when file status was last changed")
554
599
  @arg("-u", action="store_true", dest="use_atime", help="show time of last access")
555
- def cmd_ls(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
600
+ @alias("l")
601
+ @alias("dir")
602
+ def cmd_ls(self, args: argparse.Namespace, stdout: TextIO) -> bool:
556
603
  """list directory contents"""
557
604
 
558
605
  path = self.resolve_path(args.path)
559
606
 
560
607
  if args.use_ctime and args.use_atime:
561
608
  print("can't specify -c and -u at the same time")
562
- return
609
+ return False
563
610
 
564
- if not path or not path.exists():
565
- return
611
+ if not path or not self.check_dir(path):
612
+ return False
566
613
 
567
- self._print_ls(args, path, 0, stdout)
614
+ if path.is_file():
615
+ print(args.path) # mimic ls behaviour
616
+ return False
568
617
 
569
- def _print_ls(self, args: argparse.Namespace, path: fsutil.TargetPath, depth: int, stdout: TextIO) -> None:
570
- path = self.resolve_path(path)
571
- subdirs = []
618
+ print_ls(path, 0, stdout, args.l, args.human_readable, args.recursive, args.use_ctime, args.use_atime)
619
+ return False
572
620
 
573
- if path.is_dir():
574
- contents = self.scandir(path, color=True)
575
- elif path.is_file():
576
- contents = [(path, path.name)]
577
-
578
- if depth > 0:
579
- print(f"\n{str(path)}:", file=stdout)
580
-
581
- if not args.l:
582
- for target_path, name in contents:
583
- print(name, file=stdout)
584
- if target_path.is_dir():
585
- subdirs.append(target_path)
586
- else:
587
- if len(contents) > 1:
588
- print(f"total {len(contents)}", file=stdout)
589
- for target_path, name in contents:
590
- self.print_extensive_file_stat(args=args, stdout=stdout, target_path=target_path, name=name)
591
- if target_path.is_dir():
592
- subdirs.append(target_path)
593
-
594
- if args.recursive and subdirs:
595
- for subdir in subdirs:
596
- self._print_ls(args, subdir, depth + 1, stdout)
597
-
598
- def print_extensive_file_stat(
599
- self, args: argparse.Namespace, stdout: TextIO, target_path: fsutil.TargetPath, name: str
600
- ) -> None:
601
- """Print the file status."""
602
- try:
603
- entry = target_path.get()
604
- stat = entry.lstat()
605
- symlink = f" -> {entry.readlink()}" if entry.is_symlink() else ""
606
- show_time = stat.st_mtime
607
- if args.use_ctime:
608
- show_time = stat.st_ctime
609
- elif args.use_atime:
610
- show_time = stat.st_atime
611
- utc_time = datetime.datetime.utcfromtimestamp(show_time).isoformat()
612
-
613
- print(
614
- f"{stat_modestr(stat)} {stat.st_uid:4d} {stat.st_gid:4d} {stat.st_size:6d} {utc_time} {name}{symlink}",
615
- file=stdout,
616
- )
621
+ @arg("path", nargs="?")
622
+ def cmd_ll(self, args: argparse.Namespace, stdout: TextIO) -> bool:
623
+ """alias for ls -la"""
624
+ args = extend_args(args, self.cmd_ls)
625
+ args.l = True # noqa: E741
626
+ args.a = True
627
+ return self.cmd_ls(args, stdout)
617
628
 
618
- except FileNotFoundError:
619
- print(
620
- f"?????????? ? ? ? ????-??-??T??:??:??.?????? {name}",
621
- file=stdout,
622
- )
629
+ @arg("path", nargs="?")
630
+ def cmd_tree(self, args: argparse.Namespace, stdout: TextIO) -> bool:
631
+ """alias for ls -R"""
632
+ args = extend_args(args, self.cmd_ls)
633
+ args.recursive = True
634
+ return self.cmd_ls(args, stdout)
623
635
 
624
636
  @arg("path", nargs="?")
625
- @arg("-name", default="*")
626
- @arg("-iname")
627
- def cmd_find(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
637
+ @arg("-name", default="*", help="path to match with")
638
+ @arg("-iname", help="like -name, but the match is case insensitive")
639
+ @arg("-atime", type=int, help="file was last accessed n*24 hours ago")
640
+ @arg("-mtime", type=int, help="file was last modified n*24 hours ago")
641
+ @arg("-ctime", type=int, help="file (windows) or metadata (unix) was last changed n*24 hours ago")
642
+ @arg("-btime", type=int, help="file was born n*24 hours ago (ext4)")
643
+ def cmd_find(self, args: argparse.Namespace, stdout: TextIO) -> bool:
628
644
  """search for files in a directory hierarchy"""
629
645
  path = self.resolve_path(args.path)
630
- if not path:
631
- return
646
+ if not path or not self.check_dir(path):
647
+ return False
648
+
649
+ matches = []
650
+ now = datetime.now(tz=timezone.utc)
651
+ do_time_compare = any([args.mtime, args.atime])
632
652
 
633
653
  if args.iname:
634
654
  pattern = re.compile(fnmatch.translate(args.iname), re.IGNORECASE)
635
655
  for f in path.rglob("*"):
636
656
  if pattern.match(f.name):
637
- print(f, file=stdout)
638
-
639
- elif args.name:
657
+ matches.append(f) if do_time_compare else print(f, file=stdout)
658
+ else:
640
659
  for f in path.rglob(args.name):
660
+ matches.append(f) if do_time_compare else print(f, file=stdout)
661
+
662
+ def compare(now: datetime, then_ts: float, offset: int) -> bool:
663
+ then = datetime.fromtimestamp(then_ts, tz=timezone.utc)
664
+ return now - timedelta(hours=offset * 24) > then
665
+
666
+ if do_time_compare:
667
+ for f in matches:
668
+ s = f.lstat()
669
+
670
+ if args.mtime and compare(now, s.st_mtime, offset=args.mtime):
671
+ continue
672
+
673
+ if args.atime and compare(now, s.st_atime, offset=args.atime):
674
+ continue
675
+
676
+ if args.ctime and compare(now, s.st_ctime, offset=args.ctime):
677
+ continue
678
+
679
+ if args.btime and compare(now, s.st_birthtime, offset=args.btime):
680
+ continue
681
+
641
682
  print(f, file=stdout)
642
683
 
684
+ return False
685
+
643
686
  @arg("path")
644
687
  @arg("-L", "--dereference", action="store_true")
645
- def cmd_stat(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
688
+ def cmd_stat(self, args: argparse.Namespace, stdout: TextIO) -> bool:
646
689
  """display file status"""
647
690
  path = self.resolve_path(args.path)
648
- if not path:
649
- return
691
+ if not path or not self.check_path(path):
692
+ return False
693
+
694
+ print_stat(path, stdout, args.dereference)
695
+ return False
696
+
697
+ @arg("path")
698
+ @arg("-d", "--dump", action="store_true")
699
+ @arg("-R", "--recursive", action="store_true")
700
+ @alias("getfattr")
701
+ def cmd_attr(self, args: argparse.Namespace, stdout: TextIO) -> bool:
702
+ """display file attributes"""
703
+ path = self.resolve_path(args.path)
704
+ if not path or not self.check_path(path):
705
+ return False
706
+
707
+ try:
708
+ if attr := path.get().attr():
709
+ print_xattr(path.name, attr, stdout)
710
+ except Exception:
711
+ pass
712
+
713
+ if args.recursive:
714
+ for child in path.rglob("*"):
715
+ try:
716
+ if child_attr := child.get().attr():
717
+ print_xattr(child, child_attr, stdout)
718
+ print()
719
+ except Exception:
720
+ pass
650
721
 
651
- symlink = f"-> {path.readlink()}" if path.is_symlink() else ""
652
-
653
- s = path.stat() if args.dereference else path.lstat()
654
-
655
- res = STAT_TEMPLATE.format(
656
- path=path,
657
- symlink=symlink,
658
- size=s.st_size,
659
- filetype="",
660
- inode=s.st_ino,
661
- nlink=s.st_nlink,
662
- modeord=oct(stat.S_IMODE(s.st_mode)),
663
- modestr=stat_modestr(s),
664
- uid=s.st_uid,
665
- gid=s.st_gid,
666
- atime=datetime.datetime.utcfromtimestamp(s.st_atime).isoformat(),
667
- mtime=datetime.datetime.utcfromtimestamp(s.st_mtime).isoformat(),
668
- ctime=datetime.datetime.utcfromtimestamp(s.st_ctime).isoformat(),
669
- )
670
- print(res, file=stdout)
722
+ return False
671
723
 
672
724
  @arg("path")
673
- def cmd_file(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
725
+ def cmd_file(self, args: argparse.Namespace, stdout: TextIO) -> bool:
674
726
  """determine file type"""
727
+
675
728
  path = self.check_file(args.path)
676
729
  if not path:
677
- return
730
+ return False
678
731
 
679
732
  fh = path.open()
680
733
 
@@ -686,11 +739,12 @@ class TargetCli(TargetCmd):
686
739
  p.wait()
687
740
  filetype = p.stdout.read().decode().split(":", 1)[1].strip()
688
741
  print(f"{path}: {filetype}", file=stdout)
742
+ return False
689
743
 
690
744
  @arg("path", nargs="+")
691
745
  @arg("-o", "--out", default=".")
692
746
  @arg("-v", "--verbose", action="store_true")
693
- def cmd_save(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
747
+ def cmd_save(self, args: argparse.Namespace, stdout: TextIO) -> bool:
694
748
  """save a common file or directory to the host filesystem"""
695
749
  dst_path = pathlib.Path(args.out).resolve()
696
750
 
@@ -719,7 +773,7 @@ class TargetCli(TargetCmd):
719
773
  return diverging_path
720
774
 
721
775
  def save_path(
722
- src_path: pathlib.Path, dst_path: pathlib.Path, create_dst_subdir: Optional[pathlib.Path] = None
776
+ src_path: pathlib.Path, dst_path: pathlib.Path, create_dst_subdir: pathlib.Path | None = None
723
777
  ) -> None:
724
778
  """Save a common file or directory in src_path to dst_path.
725
779
 
@@ -798,8 +852,8 @@ class TargetCli(TargetCmd):
798
852
  try:
799
853
  first_src_path = next(src_paths)
800
854
  except StopIteration:
801
- print(f"{path}: no such file or directory")
802
- return
855
+ print(f"{path}: No such file or directory")
856
+ return False
803
857
 
804
858
  try:
805
859
  second_src_path = next(src_paths)
@@ -820,10 +874,18 @@ class TargetCli(TargetCmd):
820
874
  extra_dir = get_diverging_path(src_path, reference_path).parent
821
875
  save_path(src_path, dst_path, create_dst_subdir=extra_dir)
822
876
 
877
+ return False
878
+
823
879
  @arg("path")
824
- def cmd_cat(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
880
+ @alias("type")
881
+ def cmd_cat(self, args: argparse.Namespace, stdout: TextIO) -> bool:
825
882
  """print file content"""
826
- paths = self.resolve_glob_path(args.path)
883
+ paths = list(self.resolve_glob_path(args.path))
884
+
885
+ if not paths:
886
+ print(f"{args.path}: No such file or directory")
887
+ return False
888
+
827
889
  stdout = stdout.buffer
828
890
  for path in paths:
829
891
  path = self.check_file(path)
@@ -834,11 +896,17 @@ class TargetCli(TargetCmd):
834
896
  shutil.copyfileobj(fh, stdout)
835
897
  stdout.flush()
836
898
  print("")
899
+ return False
837
900
 
838
901
  @arg("path")
839
- def cmd_zcat(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
902
+ def cmd_zcat(self, args: argparse.Namespace, stdout: TextIO) -> bool:
840
903
  """print file content from compressed files"""
841
- paths = self.resolve_glob_path(args.path)
904
+ paths = list(self.resolve_glob_path(args.path))
905
+
906
+ if not paths:
907
+ print(f"{args.path}: No such file or directory")
908
+ return False
909
+
842
910
  stdout = stdout.buffer
843
911
  for path in paths:
844
912
  path = self.check_file(path)
@@ -849,45 +917,70 @@ class TargetCli(TargetCmd):
849
917
  shutil.copyfileobj(fh, stdout)
850
918
  stdout.flush()
851
919
 
920
+ return False
921
+
852
922
  @arg("path")
853
- def cmd_hexdump(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
854
- """print a hexdump of the first X bytes"""
923
+ @arg("-n", "--length", type=int, default=16 * 20, help="amount of bytes to read")
924
+ @arg("-s", "--skip", type=int, default=0, help="skip offset bytes from the beginning")
925
+ @arg("-p", "--hex", action="store_true", default=False, help="output in plain hexdump style")
926
+ @arg("-C", "--canonical", action="store_true")
927
+ @alias("xxd")
928
+ def cmd_hexdump(self, args: argparse.Namespace, stdout: TextIO) -> bool:
929
+ """print a hexdump of a file"""
855
930
  path = self.check_file(args.path)
856
931
  if not path:
857
- return
932
+ return False
933
+
934
+ fh = path.open("rb")
935
+ if args.skip > 0:
936
+ fh.seek(args.skip + 1)
937
+
938
+ if args.hex:
939
+ print(fh.read(args.length).hex(), file=stdout)
940
+ else:
941
+ print(hexdump(fh.read(args.length), output="string"), file=stdout)
858
942
 
859
- print(hexdump(path.open().read(16 * 20), output="string"), file=stdout)
943
+ return False
860
944
 
861
945
  @arg("path")
862
- def cmd_hash(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
946
+ @alias("digest")
947
+ @alias("shasum")
948
+ def cmd_hash(self, args: argparse.Namespace, stdout: TextIO) -> bool:
863
949
  """print the MD5, SHA1 and SHA256 hashes of a file"""
864
950
  path = self.check_file(args.path)
865
951
  if not path:
866
- return
952
+ return False
867
953
 
868
954
  md5, sha1, sha256 = path.get().hash()
869
955
  print(f"MD5:\t{md5}\nSHA1:\t{sha1}\nSHA256:\t{sha256}", file=stdout)
956
+ return False
870
957
 
871
958
  @arg("path")
872
- def cmd_less(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
959
+ @alias("head")
960
+ @alias("more")
961
+ def cmd_less(self, args: argparse.Namespace, stdout: TextIO) -> bool:
873
962
  """open the first 10 MB of a file with less"""
874
963
  path = self.check_file(args.path)
875
964
  if not path:
876
- return
965
+ return False
877
966
 
878
967
  pydoc.pager(path.open("rt", errors="ignore").read(10 * 1024 * 1024))
968
+ return False
879
969
 
880
970
  @arg("path")
881
- def cmd_zless(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
971
+ @alias("zhead")
972
+ @alias("zmore")
973
+ def cmd_zless(self, args: argparse.Namespace, stdout: TextIO) -> bool:
882
974
  """open the first 10 MB of a compressed file with zless"""
883
975
  path = self.check_file(args.path)
884
976
  if not path:
885
- return
977
+ return False
886
978
 
887
979
  pydoc.pager(fsutil.open_decompress(path, "rt").read(10 * 1024 * 1024))
980
+ return False
888
981
 
889
982
  @arg("path", nargs="+")
890
- def cmd_readlink(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
983
+ def cmd_readlink(self, args: argparse.Namespace, stdout: TextIO) -> bool:
891
984
  """print resolved symbolic links or canonical file names"""
892
985
  for path in args.path:
893
986
  path = self.resolve_path(path)
@@ -896,12 +989,14 @@ class TargetCli(TargetCmd):
896
989
 
897
990
  print(path.get().readlink(), file=stdout)
898
991
 
992
+ return False
993
+
899
994
  @arg("path", nargs="?", help="load a hive from the given path")
900
- def cmd_registry(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
995
+ def cmd_registry(self, args: argparse.Namespace, stdout: TextIO) -> bool:
901
996
  """drop into a registry shell"""
902
997
  if self.target.os == "linux":
903
998
  run_cli(UnixConfigTreeCli(self.target))
904
- return
999
+ return False
905
1000
 
906
1001
  hive = None
907
1002
 
@@ -909,7 +1004,7 @@ class TargetCli(TargetCmd):
909
1004
  if args.path:
910
1005
  path = self.check_file(args.path)
911
1006
  if not path:
912
- return
1007
+ return False
913
1008
 
914
1009
  hive = regutil.RegfHive(path)
915
1010
  clikey = f"registry_{path}"
@@ -918,26 +1013,26 @@ class TargetCli(TargetCmd):
918
1013
  cli = self._clicache[clikey]
919
1014
  except KeyError:
920
1015
  if not hive and not RegistryCli.check_compatible(self.target):
921
- return
1016
+ return False
922
1017
 
923
1018
  cli = RegistryCli(self.target, hive)
924
1019
  self._clicache[clikey] = cli
925
1020
 
926
1021
  run_cli(cli)
927
-
928
- # Print an additional empty newline after exit
929
1022
  print()
1023
+ return False
930
1024
 
931
1025
  @arg("targets", metavar="TARGETS", nargs="*", help="targets to load")
932
1026
  @arg("-p", "--python", action="store_true", help="(I)Python shell")
933
1027
  @arg("-r", "--registry", action="store_true", help="registry shell")
934
- def cmd_enter(self, args: argparse.Namespace, stdout: TextIO) -> None:
1028
+ def cmd_enter(self, args: argparse.Namespace, stdout: TextIO) -> bool:
935
1029
  """load one or more files as sub-targets and drop into a sub-shell"""
936
1030
  paths = [self.resolve_path(path) for path in args.targets]
937
1031
 
938
1032
  if args.python:
939
1033
  # Quick path that doesn't require CLI caching
940
- return open_shell(paths, args.python, args.registry)
1034
+ open_shell(paths, args.python, args.registry)
1035
+ return False
941
1036
 
942
1037
  clikey = tuple(str(path) for path in paths)
943
1038
  try:
@@ -946,33 +1041,32 @@ class TargetCli(TargetCmd):
946
1041
  targets = list(Target.open_all(paths))
947
1042
  cli = create_cli(targets, RegistryCli if args.registry else TargetCli)
948
1043
  if not cli:
949
- return
1044
+ return False
950
1045
 
951
1046
  self._clicache[clikey] = cli
952
1047
 
953
1048
  run_cli(cli)
954
-
955
- # Print an additional empty newline after exit
956
1049
  print()
1050
+ return False
957
1051
 
958
1052
 
959
1053
  class UnixConfigTreeCli(TargetCli):
960
1054
  def __init__(self, target: Target):
961
1055
  TargetCmd.__init__(self, target)
962
1056
  self.config_tree = target.etc()
963
- self.prompt_base = target.name
1057
+ self.prompt_base = _target_name(target)
964
1058
 
965
1059
  self.cwd = None
966
1060
  self.chdir("/")
967
1061
 
968
1062
  @property
969
1063
  def prompt(self) -> str:
970
- return f"{self.prompt_base}/config_tree {self.cwd}> "
1064
+ return f"(config tree) {self.prompt_base}:{self.cwd}$ "
971
1065
 
972
1066
  def check_compatible(target: Target) -> bool:
973
1067
  return target.has_function("etc")
974
1068
 
975
- def resolve_path(self, path: Optional[Union[str, fsutil.TargetPath]]) -> fsutil.TargetPath:
1069
+ def resolve_path(self, path: str | fsutil.TargetPath | None) -> fsutil.TargetPath:
976
1070
  if not path:
977
1071
  return self.cwd
978
1072
 
@@ -1006,12 +1100,12 @@ class UnixConfigTreeCli(TargetCli):
1006
1100
  class RegistryCli(TargetCmd):
1007
1101
  """CLI for browsing the registry."""
1008
1102
 
1009
- def __init__(self, target: Target, registry: Optional[regutil.RegfHive] = None):
1010
- TargetCmd.__init__(self, target)
1011
- self.registry = registry or target.registry
1103
+ def __init__(self, target: Target, registry: regutil.RegfHive | None = None):
1104
+ self.prompt_base = _target_name(target)
1012
1105
 
1013
- self.prompt_base = target.name
1106
+ TargetCmd.__init__(self, target)
1014
1107
 
1108
+ self.registry = registry or target.registry
1015
1109
  self.cwd = None
1016
1110
  self.chdir("\\")
1017
1111
 
@@ -1024,8 +1118,7 @@ class RegistryCli(TargetCmd):
1024
1118
 
1025
1119
  @property
1026
1120
  def prompt(self) -> str:
1027
- prompt_end = self.cwd.strip("\\")
1028
- return f"{self.prompt_base}/registry {prompt_end}> "
1121
+ return "(registry) " + self.prompt_ps1.format(base=self.prompt_base, cwd=self.cwd)
1029
1122
 
1030
1123
  def completedefault(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
1031
1124
  path = line[:begidx].rsplit(" ")[-1]
@@ -1065,9 +1158,7 @@ class RegistryCli(TargetCmd):
1065
1158
  if self.check_key(path):
1066
1159
  self.cwd = "\\" + path.strip("\\")
1067
1160
 
1068
- def scandir(
1069
- self, path: str, color: bool = False
1070
- ) -> list[tuple[Union[regutil.RegistryKey, regutil.RegistryValue], str]]:
1161
+ def scandir(self, path: str, color: bool = False) -> list[tuple[regutil.RegistryKey | regutil.RegistryValue, str]]:
1071
1162
  try:
1072
1163
  key = self.resolve_key(path)
1073
1164
  except RegistryError:
@@ -1083,56 +1174,95 @@ class RegistryCli(TargetCmd):
1083
1174
  r.sort(key=lambda e: e[0].name)
1084
1175
  return r
1085
1176
 
1086
- def do_cd(self, line: str) -> Optional[bool]:
1177
+ def do_cd(self, line: str) -> bool:
1087
1178
  """change subkey"""
1179
+ if line == "..":
1180
+ try:
1181
+ self.resolve_key(self.cwd + "\\..")
1182
+ except RegistryError:
1183
+ self.do_up(line)
1184
+ return False
1185
+
1088
1186
  self.chdir(line)
1187
+ return False
1089
1188
 
1090
- def do_up(self, line: str) -> Optional[bool]:
1189
+ def do_up(self, line: str) -> bool:
1091
1190
  """go up a subkey"""
1092
1191
  parent = self.cwd.rpartition("\\")[0]
1093
1192
  if not parent:
1094
1193
  parent = "\\"
1095
1194
  self.chdir(parent)
1195
+ return False
1096
1196
 
1097
- def do_pwd(self, line: str) -> Optional[bool]:
1197
+ def do_pwd(self, line: str) -> bool:
1098
1198
  """print current path"""
1099
- print(self.cwd)
1199
+ print(self.cwd.lstrip("\\"))
1200
+ return False
1100
1201
 
1101
- def do_recommend(self, line: str) -> Optional[bool]:
1202
+ def do_recommend(self, line: str) -> bool:
1102
1203
  """recommend a key"""
1103
1204
  print(random.choice([name for _, name in self.scandir(None)]))
1205
+ return False
1104
1206
 
1105
1207
  @arg("path", nargs="?")
1106
- def cmd_ls(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
1208
+ def cmd_ls(self, args: argparse.Namespace, stdout: TextIO) -> bool:
1107
1209
  key = self.check_key(args.path)
1108
1210
  if not key:
1109
- return
1211
+ return False
1110
1212
 
1111
1213
  r = self.scandir(key, color=True)
1112
1214
  print("\n".join([name for _, name in r]), file=stdout)
1215
+ return False
1113
1216
 
1114
1217
  @arg("value")
1115
- def cmd_cat(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
1218
+ def cmd_cat(self, args: argparse.Namespace, stdout: TextIO) -> bool:
1116
1219
  value = self.check_value(args.value)
1117
1220
  if not value:
1118
- return
1221
+ return False
1119
1222
 
1120
1223
  print(repr(value.value), file=stdout)
1224
+ return False
1225
+
1226
+ @arg("value")
1227
+ @arg("-p", "--hex", action="store_true")
1228
+ @alias("xxd")
1229
+ def cmd_hexdump(self, args: argparse.Namespace, stdout: TextIO) -> bool:
1230
+ value = self.check_value(args.value)
1231
+ if not value:
1232
+ return False
1121
1233
 
1234
+ if args.hex:
1235
+ print(value.value.hex(), file=stdout)
1236
+ else:
1237
+ print(hexdump(value.value, output="string"), file=stdout)
1122
1238
 
1123
- def fmt_ls_colors(ft: str, name: str) -> str:
1124
- """Helper method to colorize strings according to LS_COLORS."""
1125
- try:
1126
- return LS_COLORS[ft].format(name)
1127
- except KeyError:
1128
- pass
1239
+ return False
1240
+
1241
+
1242
+ def arg_str_to_arg_list(args: str) -> list[str]:
1243
+ """Convert a commandline string to a list of command line arguments."""
1244
+ lexer = shlex.shlex(args, posix=True, punctuation_chars=True)
1245
+ lexer.wordchars += "$"
1246
+ lexer.whitespace_split = True
1247
+ return list(lexer)
1129
1248
 
1130
- try:
1131
- return LS_COLORS[fsutil.splitext(name)[1]].format(name)
1132
- except KeyError:
1133
- pass
1134
1249
 
1135
- return name
1250
+ def extend_args(args: argparse.Namespace, func: Callable) -> argparse.Namespace:
1251
+ """Extend the arguments of the given ``func`` with the provided ``argparse.Namespace``."""
1252
+ for short, kwargs in func.__args__:
1253
+ name = kwargs.get("dest", short[-1]).lstrip("-").replace("-", "_")
1254
+ if not hasattr(args, name):
1255
+ setattr(args, name, None)
1256
+ return args
1257
+
1258
+
1259
+ def _target_name(target: Target) -> str:
1260
+ """Return a target name for cmd.Cmd base prompts."""
1261
+
1262
+ if target.has_function("domain") and target.domain:
1263
+ return f"{target.name}.{target.domain}"
1264
+
1265
+ return target.name
1136
1266
 
1137
1267
 
1138
1268
  @contextmanager
@@ -1200,15 +1330,7 @@ def build_pipe_stdout(pipe_parts: list[str]) -> Iterator[TextIO]:
1200
1330
  yield pipe_stdin
1201
1331
 
1202
1332
 
1203
- def stat_modestr(st: fsutil.stat_result) -> str:
1204
- """Helper method for generating a mode string from a numerical mode value."""
1205
- is_dir = "d" if stat.S_ISDIR(st.st_mode) else "-"
1206
- dic = {"7": "rwx", "6": "rw-", "5": "r-x", "4": "r--", "0": "---"}
1207
- perm = str(oct(st.st_mode)[-3:])
1208
- return is_dir + "".join(dic.get(x, x) for x in perm)
1209
-
1210
-
1211
- def open_shell(targets: list[Union[str, pathlib.Path]], python: bool, registry: bool) -> None:
1333
+ def open_shell(targets: list[str | pathlib.Path], python: bool, registry: bool) -> None:
1212
1334
  """Helper method for starting a regular, Python or registry shell for one or multiple targets."""
1213
1335
  targets = list(Target.open_all(targets))
1214
1336
 
@@ -1246,7 +1368,7 @@ def python_shell(targets: list[Target]) -> None:
1246
1368
  print()
1247
1369
 
1248
1370
 
1249
- def create_cli(targets: list[Target], cli_cls: type[TargetCmd]) -> Optional[cmd.Cmd]:
1371
+ def create_cli(targets: list[Target], cli_cls: type[TargetCmd]) -> cmd.Cmd | None:
1250
1372
  """Helper method for instatiating the appropriate CLI."""
1251
1373
  if len(targets) == 1:
1252
1374
  target = targets[0]
@@ -1275,6 +1397,9 @@ def run_cli(cli: cmd.Cmd) -> None:
1275
1397
  return
1276
1398
 
1277
1399
  except KeyboardInterrupt:
1400
+ # Run postloop so the interrupted command is added to the history file
1401
+ cli.postloop()
1402
+
1278
1403
  # Add a line when pressing ctrl+c, so the next one starts at a new line
1279
1404
  print()
1280
1405
 
@@ -1286,6 +1411,8 @@ def run_cli(cli: cmd.Cmd) -> None:
1286
1411
  print(f"*** Unhandled error: {e}")
1287
1412
  print("If you wish to see the full debug trace, enable debug mode.")
1288
1413
 
1414
+ cli.postloop()
1415
+
1289
1416
 
1290
1417
  @catch_sigpipe
1291
1418
  def main() -> None:
@@ -1311,8 +1438,7 @@ def main() -> None:
1311
1438
  args.targets = args_to_uri(args.targets, args.loader, rest) if args.loader else args.targets
1312
1439
  process_generic_arguments(args)
1313
1440
 
1314
- # For the shell tool we want -q to log slightly more then just CRITICAL
1315
- # messages.
1441
+ # For the shell tool we want -q to log slightly more then just CRITICAL messages.
1316
1442
  if args.quiet:
1317
1443
  logging.getLogger("dissect").setLevel(level=logging.ERROR)
1318
1444