picklock 0.1.0__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.
picklock/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """
4
+ Picklock — a plain-text terminal client for PyMemoryEditor.
5
+
6
+ Picklock exposes PyMemoryEditor's process introspection, memory scanning and
7
+ read/write features through an interactive shell: ASCII result tables, a
8
+ one-line prompt, no curses, no GUI toolkit, no colour beyond a single
9
+ highlight for errors. It runs anywhere Python does — a desktop, a headless
10
+ server, an SSH session, a CI job.
11
+
12
+ The package is a *client*: every memory operation is performed by
13
+ PyMemoryEditor, which Picklock depends on but does not vendor.
14
+ """
15
+
16
+ __author__ = "Jean Loui Bernard Silva de Jesus"
17
+ __version__ = "0.1.0"
18
+
19
+ from .errors import CommandError, NoProcessError, PicklockError
20
+ from .session import Session
21
+ from .shell import Shell
22
+
23
+ __all__ = (
24
+ "CommandError",
25
+ "NoProcessError",
26
+ "PicklockError",
27
+ "Session",
28
+ "Shell",
29
+ "__author__",
30
+ "__version__",
31
+ )
picklock/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """Entry point for ``python -m picklock``."""
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
picklock/addressing.py ADDED
@@ -0,0 +1,259 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """
4
+ The little address language every command shares.
5
+
6
+ Anywhere Picklock takes an address it takes an *expression*, so the workflows
7
+ that matter can be typed on one line instead of copied between commands:
8
+
9
+ =============================== =========================================
10
+ ``0x7ffee3a01000`` / ``140...`` a literal, hex or decimal
11
+ ``game.exe+0x1234`` a module base plus a static offset (ASLR-proof)
12
+ ``"libfoo-1.so"+0x20`` the same, quoted when the name has a ``-``
13
+ ``[game.exe+0x1234]+0x10`` read the pointer there, then add ``0x10``
14
+ ``[[base+0x8]+0x20]+0x4`` a pointer chain, nested as deep as you like
15
+ ``#3`` the address on row 3 of the last scan
16
+ =============================== =========================================
17
+
18
+ The grammar is deliberately tiny — brackets, ``+``, ``-`` and the three kinds
19
+ of term above — because an address expression that needs its own manual page
20
+ has stopped being a convenience.
21
+ """
22
+
23
+ from typing import TYPE_CHECKING, Any, List, NamedTuple, Optional
24
+
25
+ from .errors import CommandError
26
+
27
+ if TYPE_CHECKING: # pragma: no cover - typing only
28
+ from .session import Session
29
+
30
+ _IDENT_CHARS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.")
31
+
32
+ #: Token kinds produced by :func:`_tokenize`.
33
+ _NUMBER, _IDENT, _RESULT, _OPEN, _CLOSE, _PLUS, _MINUS = range(7)
34
+
35
+
36
+ class _Token(NamedTuple):
37
+ """One lexeme, with what it looked like and whether a space came first.
38
+
39
+ The source text and the spacing are what let a module name containing a
40
+ hyphen be told from a subtraction: ``_ssl.cpython-311-darwin.so`` is one
41
+ name, ``game.exe - 0x10`` is a sum.
42
+ """
43
+
44
+ kind: int
45
+ value: Any
46
+ source: str
47
+ spaced_before: bool
48
+
49
+
50
+ def _tokenize(text: str) -> List["_Token"]:
51
+ tokens: List[_Token] = []
52
+ index = 0
53
+ length = len(text)
54
+ spaced = False
55
+
56
+ def emit(kind: int, value: Any, source: str) -> None:
57
+ tokens.append(_Token(kind, value, source, spaced))
58
+
59
+ while index < length:
60
+ char = text[index]
61
+
62
+ if char.isspace():
63
+ index += 1
64
+ spaced = True
65
+ continue
66
+
67
+ if char == "[":
68
+ emit(_OPEN, "[", "[")
69
+ index += 1
70
+ spaced = False
71
+ continue
72
+
73
+ if char == "]":
74
+ emit(_CLOSE, "]", "]")
75
+ index += 1
76
+ spaced = False
77
+ continue
78
+
79
+ if char == "+":
80
+ emit(_PLUS, "+", "+")
81
+ index += 1
82
+ spaced = False
83
+ continue
84
+
85
+ if char == "-":
86
+ emit(_MINUS, "-", "-")
87
+ index += 1
88
+ spaced = False
89
+ continue
90
+
91
+ if char == "#":
92
+ index += 1
93
+ start = index
94
+ while index < length and text[index].isdigit():
95
+ index += 1
96
+ if start == index:
97
+ raise CommandError("'#' must be followed by a result number, e.g. #3.")
98
+ emit(_RESULT, int(text[start:index]), text[start - 1 : index])
99
+ spaced = False
100
+ continue
101
+
102
+ if char in ("'", '"'):
103
+ end = text.find(char, index + 1)
104
+ if end == -1:
105
+ raise CommandError(f"Unterminated {char} in address expression.")
106
+ emit(_IDENT, text[index + 1 : end], text[index : end + 1])
107
+ index = end + 1
108
+ spaced = False
109
+ continue
110
+
111
+ if char.lower() == "0" and text[index : index + 2].lower() == "0x":
112
+ start = index
113
+ index += 2
114
+ while index < length and text[index] in "0123456789abcdefABCDEF_":
115
+ index += 1
116
+ try:
117
+ emit(_NUMBER, int(text[start:index].replace("_", ""), 16), text[start:index])
118
+ spaced = False
119
+ except ValueError:
120
+ raise CommandError(f"{text[start:index]!r} is not a hex number.")
121
+ continue
122
+
123
+ if char in _IDENT_CHARS:
124
+ start = index
125
+ while index < length and text[index] in _IDENT_CHARS:
126
+ index += 1
127
+ word = text[start:index]
128
+ # A run of digits is a decimal literal; anything else (including
129
+ # "game.exe" and "libc.so.6") is a module name.
130
+ if word.replace("_", "").isdigit():
131
+ emit(_NUMBER, int(word.replace("_", "")), word)
132
+ else:
133
+ emit(_IDENT, word, word)
134
+ spaced = False
135
+ continue
136
+
137
+ raise CommandError(f"Unexpected character {char!r} in address expression.")
138
+
139
+ return tokens
140
+
141
+
142
+ class _Parser:
143
+ """Recursive-descent parser over the token list. One expression per call."""
144
+
145
+ def __init__(self, tokens: List[_Token], session: "Session"):
146
+ self.tokens = tokens
147
+ self.session = session
148
+ self.position = 0
149
+
150
+ def peek(self, ahead: int = 0) -> Optional[_Token]:
151
+ index = self.position + ahead
152
+ if index < len(self.tokens):
153
+ return self.tokens[index]
154
+ return None
155
+
156
+ def next(self) -> _Token:
157
+ token = self.peek()
158
+ if token is None:
159
+ raise CommandError("Unexpected end of address expression.")
160
+ self.position += 1
161
+ return token
162
+
163
+ def parse_expression(self) -> int:
164
+ value = self.parse_term()
165
+ while True:
166
+ token = self.peek()
167
+ if token is None or token.kind not in (_PLUS, _MINUS):
168
+ return value
169
+ self.position += 1
170
+ operand = self.parse_term()
171
+ value = value + operand if token.kind == _PLUS else value - operand
172
+
173
+ def parse_term(self) -> int:
174
+ token = self.next()
175
+
176
+ if token.kind == _NUMBER:
177
+ return int(token.value)
178
+
179
+ if token.kind == _RESULT:
180
+ return self.session.result_address(int(token.value))
181
+
182
+ if token.kind == _IDENT:
183
+ return self.session.module_base(self._module_name(str(token.value)))
184
+
185
+ if token.kind == _OPEN:
186
+ inner = self.parse_expression()
187
+ closing = self.next()
188
+ if closing.kind != _CLOSE:
189
+ raise CommandError("Missing ']' in address expression.")
190
+ return self.session.read_pointer(inner)
191
+
192
+ raise CommandError("Expected an address, a module name or '[' here.")
193
+
194
+ def _module_name(self, name: str) -> str:
195
+ """Rejoin a module name the tokenizer split on a hyphen.
196
+
197
+ A hyphen is a subtraction sign and also a perfectly ordinary character
198
+ in a library's name — ``_ssl.cpython-311-darwin.so`` is what every
199
+ Python process is full of. The two are told apart by asking: the pieces
200
+ are rejoined only while they are written without spaces *and* the
201
+ result names a module that is actually loaded. So
202
+ ``game.exe-0x10`` stays a subtraction, because no such module exists,
203
+ and ``game.exe - 0x10`` never even gets here.
204
+ """
205
+ best, consumed = name, 0
206
+ candidate = name
207
+ ahead = 0
208
+
209
+ while True:
210
+ minus, part = self.peek(ahead), self.peek(ahead + 1)
211
+ if minus is None or part is None:
212
+ break
213
+ if minus.kind != _MINUS or minus.spaced_before or part.spaced_before:
214
+ break
215
+ if part.kind not in (_IDENT, _NUMBER):
216
+ break
217
+
218
+ candidate += "-" + part.source
219
+ ahead += 2
220
+ if self.session.knows_module(candidate):
221
+ best, consumed = candidate, ahead
222
+
223
+ self.position += consumed
224
+ return best
225
+
226
+
227
+ def parse_address(text: str, session: "Session") -> int:
228
+ """Evaluate an address expression against ``session``.
229
+
230
+ :raises CommandError: on any syntax error, unknown module, out-of-range
231
+ result index or unreadable dereference — all of which are the user's
232
+ to correct, so the shell keeps running.
233
+ """
234
+ tokens = _tokenize(text)
235
+ if not tokens:
236
+ raise CommandError("Empty address.")
237
+
238
+ parser = _Parser(tokens, session)
239
+ address = parser.parse_expression()
240
+
241
+ if parser.peek() is not None:
242
+ raise CommandError(f"Trailing characters in address {text!r}.")
243
+ if address < 0:
244
+ raise CommandError(f"Address expression {text!r} resolved below zero.")
245
+ return address
246
+
247
+
248
+ def parse_int(text: str, what: str = "value") -> int:
249
+ """Parse a plain integer argument (hex or decimal), not an address."""
250
+ cleaned = text.strip().replace("_", "")
251
+ try:
252
+ if cleaned[:2].lower() == "0x":
253
+ return int(cleaned, 16)
254
+ return int(cleaned, 10)
255
+ except (ValueError, IndexError):
256
+ raise CommandError(f"{text!r} is not a valid {what}.")
257
+
258
+
259
+ __all__ = ("parse_address", "parse_int")
picklock/cli.py ADDED
@@ -0,0 +1,262 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """
4
+ The ``picklock`` entry point.
5
+
6
+ Run bare, it opens the interactive shell. Given commands — with ``-e``, as a
7
+ trailing command line, in a file, or on standard input — it runs them and
8
+ exits with a status, so the same vocabulary works inside a script, an SSH
9
+ session or a CI job:
10
+
11
+ picklock # the shell
12
+ picklock ps:list chrome # one command, then exit
13
+ picklock -p 4242 -e "memory:read game.exe+0x10" # attach, read, exit
14
+ picklock -f setup.txt # a file of commands
15
+ echo "ps:list" | picklock # a pipe
16
+ """
17
+
18
+ import argparse
19
+ import shlex
20
+ import sys
21
+ from typing import List, Optional, Sequence
22
+
23
+ from . import dependencies
24
+ from .commands import top_level_listing
25
+ from .commands.alias_commands import restore as restore_aliases
26
+ from .commands.session_commands import restore as restore_settings
27
+ from .commands.session_commands import version_report
28
+ from .errors import CommandError, PicklockError
29
+ from .output import Printer
30
+ from .session import Session
31
+ from .shell import Shell
32
+
33
+
34
+ def _format_commands() -> str:
35
+ """The same layered summary the shell's own ``help`` prints.
36
+
37
+ Every word you can type first, and nothing below it — rather than all
38
+ thirty-odd commands at once, which is a wall rather than an answer.
39
+ """
40
+ rows = top_level_listing()
41
+ width = max(len(signature) for signature, _ in rows)
42
+
43
+ lines: List[str] = ["picklock commands:", ""]
44
+ # Four spaces between the columns, as the shell's own listings use.
45
+ lines += [f" {signature.ljust(width)} {summary}" for signature, summary in rows]
46
+ lines += [
47
+ "",
48
+ "Run 'picklock help <command>' for what a command takes, or",
49
+ "'picklock help' for the topics ('types', 'address', 'scanning').",
50
+ ]
51
+ return "\n".join(lines)
52
+
53
+
54
+ def build_parser() -> argparse.ArgumentParser:
55
+ parser = argparse.ArgumentParser(
56
+ prog="picklock",
57
+ description=(
58
+ "A terminal client for PyMemoryEditor: read, write and scan the "
59
+ "memory of a running process from any shell, on Windows, Linux or "
60
+ "macOS."
61
+ ),
62
+ epilog=_format_commands(),
63
+ formatter_class=argparse.RawDescriptionHelpFormatter,
64
+ )
65
+
66
+ target = parser.add_argument_group("target")
67
+ target.add_argument("-p", "--pid", type=int, help="attach to this PID at startup")
68
+ target.add_argument("-n", "--name", help="attach to this process name at startup")
69
+ target.add_argument(
70
+ "-i",
71
+ "--ignore-case",
72
+ action="store_true",
73
+ help="match --name regardless of case",
74
+ )
75
+ target.add_argument(
76
+ "--partial",
77
+ action="store_true",
78
+ help="match --name as a substring ('chrome' finds 'chrome.exe')",
79
+ )
80
+
81
+ running = parser.add_argument_group("commands")
82
+ running.add_argument(
83
+ "-e",
84
+ "--execute",
85
+ action="append",
86
+ default=[],
87
+ metavar="COMMAND",
88
+ help="run a command and exit; repeatable, run in order",
89
+ )
90
+ running.add_argument(
91
+ "-f",
92
+ "--file",
93
+ metavar="FILE",
94
+ help="run the commands in FILE and exit",
95
+ )
96
+
97
+ output = parser.add_argument_group("output")
98
+ output.add_argument(
99
+ "--no-color", action="store_true", help="never emit ANSI colour"
100
+ )
101
+ output.add_argument(
102
+ "--no-timing", action="store_true", help="omit the elapsed-time footer"
103
+ )
104
+ output.add_argument(
105
+ "--limit",
106
+ type=int,
107
+ metavar="N",
108
+ help="rows printed per result table (0 for no limit)",
109
+ )
110
+ output.add_argument(
111
+ "-q", "--quiet", action="store_true", help="skip the welcome banner"
112
+ )
113
+ parser.add_argument(
114
+ "-v",
115
+ "--version",
116
+ action="version",
117
+ # The same block the 'version' command prints. Which PyMemoryEditor is
118
+ # underneath matters as much as which Picklock is on top, and someone
119
+ # pasting this into a bug report should not have to open the shell to
120
+ # get the useful half.
121
+ version=version_report(),
122
+ )
123
+ parser.add_argument(
124
+ "command",
125
+ nargs=argparse.REMAINDER,
126
+ help="a single command to run, e.g. 'picklock ps:list chrome'",
127
+ )
128
+ return parser
129
+
130
+
131
+ def _startup_lines(options: argparse.Namespace) -> List[str]:
132
+ """The commands implied by the target flags, run before anything else."""
133
+ if options.pid is None and options.name is None:
134
+ return []
135
+ if options.pid is not None and options.name is not None:
136
+ raise CommandError("Give --pid or --name, not both.")
137
+
138
+ parts = ["ps:open"]
139
+ if options.pid is not None:
140
+ parts += ["--pid", str(options.pid)]
141
+ else:
142
+ parts += ["--name", shlex.quote(options.name)]
143
+ if options.ignore_case:
144
+ parts.append("-i")
145
+ if options.partial:
146
+ parts.append("--partial")
147
+ return [" ".join(parts)]
148
+
149
+
150
+ def _batch_lines(options: argparse.Namespace, stdin) -> Optional[List[str]]:
151
+ """The commands to run non-interactively, or ``None`` for the shell.
152
+
153
+ Standard input counts only when it is *not* a terminal: a pipe or a
154
+ redirect is someone scripting Picklock, while a terminal is someone who
155
+ typed ``picklock`` and wants the prompt.
156
+ """
157
+ lines: List[str] = []
158
+
159
+ lines.extend(options.execute)
160
+
161
+ if options.command:
162
+ lines.append(" ".join(shlex.quote(part) for part in options.command))
163
+
164
+ if options.file:
165
+ lines.append(f"source {shlex.quote(options.file)}")
166
+
167
+ if lines:
168
+ return lines
169
+
170
+ if not getattr(stdin, "isatty", lambda: True)():
171
+ return stdin.read().splitlines()
172
+
173
+ return None
174
+
175
+
176
+ def main(argv: Optional[Sequence[str]] = None) -> int:
177
+ """Run Picklock. Returns the process exit status."""
178
+ parser = build_parser()
179
+ options = parser.parse_args(argv)
180
+
181
+ printer = Printer(
182
+ color=False if options.no_color else None,
183
+ timing=not options.no_timing,
184
+ )
185
+
186
+ # Before anything touches a process: a PyMemoryEditor below the declared
187
+ # floor fails later, deep inside a scan, with an error that names a Mach
188
+ # call rather than the cause.
189
+ outdated = dependencies.check()
190
+ if outdated is not None:
191
+ printer.error(outdated)
192
+ return 2
193
+
194
+ session = Session(printer)
195
+ shell = Shell(session, printer=printer)
196
+
197
+ # The aliases the user defined in an earlier run. Loaded here rather than
198
+ # in Session, so a Session built in a test or a script touches no files
199
+ # unless it asks to.
200
+ dropped = restore_aliases(session)
201
+ if dropped:
202
+ printer.note(
203
+ "Dropped %s, whose command no longer exists: %s."
204
+ % (
205
+ "an alias" if len(dropped) == 1 else "some aliases",
206
+ ", ".join(sorted(dropped)),
207
+ )
208
+ )
209
+
210
+ # Settings come back too. Restored before --limit is applied, so a flag
211
+ # given on this run still wins over what was stored on the last one.
212
+ forgotten = restore_settings(session)
213
+ if forgotten:
214
+ printer.note(
215
+ "Ignored %s no longer recognised: %s."
216
+ % (
217
+ "a stored setting" if len(forgotten) == 1 else "stored settings",
218
+ ", ".join(sorted(forgotten)),
219
+ )
220
+ )
221
+
222
+ if options.limit is not None:
223
+ session.set_option("limit", str(options.limit))
224
+
225
+ try:
226
+ startup = _startup_lines(options)
227
+ except CommandError as error:
228
+ printer.error(str(error))
229
+ return 2
230
+
231
+ batch = _batch_lines(options, sys.stdin)
232
+ interactive = batch is None
233
+
234
+ try:
235
+ # A failed --pid/--name is fatal either way: the commands that follow
236
+ # were written for a target that is not there.
237
+ for line in startup:
238
+ if not shell.run_line(line, raise_errors=False):
239
+ return 1
240
+
241
+ if interactive:
242
+ return shell.interact(banner=not options.quiet)
243
+
244
+ try:
245
+ return shell.run_lines(batch or [], raise_errors=True)
246
+ finally:
247
+ session.close()
248
+
249
+ except PicklockError as error:
250
+ printer.error(str(error))
251
+ return 1
252
+ except KeyboardInterrupt:
253
+ printer.clear_progress()
254
+ printer.write()
255
+ return 130
256
+ except BrokenPipeError: # pragma: no cover - depends on the consumer
257
+ # 'picklock ps | head' closes the pipe early; that is not an error.
258
+ return 0
259
+
260
+
261
+ if __name__ == "__main__": # pragma: no cover
262
+ sys.exit(main())