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

Sign up to get free protection for your applications and to get access to all the features.
@@ -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