memdebug 0.2.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.
@@ -0,0 +1,242 @@
1
+ """A before/after view that shows what changed in a text, the way a code review would.
2
+
3
+ Texts are compared line by line, because a memory is usually a few lines or a whole markdown file. For a line
4
+ that was edited, the answer depends on how much of it survived:
5
+
6
+ * Mostly kept (one word swapped, a sentence appended): the line is shown once with the changed words marked,
7
+ which is what someone looking for a planted instruction needs.
8
+ * Rewritten (little in common, or the word-by-word diff would be a scatter of fragments): the old line is shown
9
+ struck out and the new line below it. Marking words inside a rewrite only produces confetti.
10
+
11
+ Long texts keep two unchanged lines either side of each change and fold the rest into one quiet line, so the
12
+ changes stay on screen. The full text is always available separately.
13
+
14
+ Every piece of text goes through block(), so it is escaped and control characters are made visible exactly
15
+ as everywhere else in the viewer.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import difflib
20
+ import re
21
+
22
+ from .html import Markup, block, el
23
+
24
+ __all__ = ["redline", "change_snippet", "MAX_REDLINE_CHARS", "MAX_DOCUMENT_CHARS", "MAX_DOCUMENT_LINES", "CONTEXT_LINES",
25
+ "MAX_SNIPPET_CHARS"]
26
+
27
+ MAX_REDLINE_CHARS = 3000 # up to this size a text is shown whole; longer texts fold their unchanged stretches
28
+ MAX_DOCUMENT_CHARS = 400_000 # beyond these the comparison would be slow; the caller falls back
29
+ MAX_DOCUMENT_LINES = 6000
30
+ MAX_SNIPPET_CHARS = 20_000 # a single line longer than this is never compared word by word
31
+ CONTEXT_LINES = 2 # unchanged lines kept on each side of a change
32
+ MAX_INLINE_SEGMENTS = 4 # more separate edits than this in one line and it reads as a rewrite
33
+ _TOKENS = re.compile(r"\s+|\w+|[^\w\s]", re.UNICODE)
34
+
35
+
36
+ # -- word level -------------------------------------------------------------------------------------------------
37
+
38
+ def _opcodes(old_tokens: list[str], new_tokens: list[str]) -> list[tuple]:
39
+ """Word-level opcodes where two changes separated only by spaces count as one change.
40
+
41
+ Without this, "likes tea" becoming "ignore all previous instructions" is reported as two edits with a
42
+ space between them."""
43
+ ops = difflib.SequenceMatcher(None, old_tokens, new_tokens, autojunk=False).get_opcodes()
44
+ merged: list[tuple] = []
45
+ k = 0
46
+ while k < len(ops):
47
+ tag, i1, i2, _, _ = ops[k]
48
+ if (tag == "equal" and merged and merged[-1][0] != "equal" and k + 1 < len(ops) and ops[k + 1][0] != "equal"
49
+ and all(token.isspace() for token in old_tokens[i1:i2])):
50
+ before, after = merged.pop(), ops[k + 1]
51
+ merged.append(("replace", before[1], after[2], before[3], after[4]))
52
+ k += 2
53
+ continue
54
+ merged.append(ops[k])
55
+ k += 1
56
+ return merged
57
+
58
+
59
+ def _words_count(tokens: list[str]) -> int:
60
+ return sum(1 for token in tokens if not token.isspace())
61
+
62
+
63
+ def _inline_reads_well(ops: list[tuple], old_tokens: list[str], new_tokens: list[str]) -> bool:
64
+ """Would marking the changed words inside the line be easy to read?"""
65
+ total = _words_count(old_tokens) + _words_count(new_tokens)
66
+ if total == 0:
67
+ return True
68
+ kept = 2 * sum(_words_count(old_tokens[i1:i2]) for tag, i1, i2, _, _ in ops if tag == "equal") / total
69
+ segments = sum(1 for op in ops if op[0] != "equal")
70
+ if kept < 0.2: # almost nothing in common: only tiny texts are clearer inline
71
+ return len("".join(old_tokens)) + len("".join(new_tokens)) <= 60
72
+ return segments <= MAX_INLINE_SEGMENTS or kept >= 0.75
73
+
74
+
75
+ def _marked(text: str, kind: str) -> list[Markup]:
76
+ """Highlight the words of a changed run but leave the spaces around them unmarked."""
77
+ if not text:
78
+ return []
79
+ core = text.strip()
80
+ if not core:
81
+ return [block(text)]
82
+ start = text.index(core)
83
+ lead, trail = text[:start], text[start + len(core):]
84
+ return [piece for piece in (block(lead) if lead else None, el("span", block(core), class_=kind),
85
+ block(trail) if trail else None) if piece is not None]
86
+
87
+
88
+ def _plan(old_line: str, new_line: str) -> tuple[list[tuple], list[str], list[str]] | None:
89
+ """The word-level comparison of two lines, or None when it should be shown as a rewrite instead."""
90
+ if len(old_line) > MAX_SNIPPET_CHARS or len(new_line) > MAX_SNIPPET_CHARS:
91
+ return None
92
+ old_tokens, new_tokens = _TOKENS.findall(old_line), _TOKENS.findall(new_line)
93
+ ops = _opcodes(old_tokens, new_tokens)
94
+ return (ops, old_tokens, new_tokens) if _inline_reads_well(ops, old_tokens, new_tokens) else None
95
+
96
+
97
+ def _inline_pieces(ops: list[tuple], old_tokens: list[str], new_tokens: list[str]) -> list[Markup]:
98
+ pieces: list[Markup] = []
99
+ for tag, i1, i2, j1, j2 in ops:
100
+ old, new = "".join(old_tokens[i1:i2]), "".join(new_tokens[j1:j2])
101
+ if tag == "equal":
102
+ pieces.append(block(old))
103
+ continue
104
+ pieces += _marked(old, "rm")
105
+ pieces += _marked(new, "ins")
106
+ return pieces
107
+
108
+
109
+ # -- line level -------------------------------------------------------------------------------------------------
110
+
111
+ def _line(*pieces: Markup, kind: str | None = None) -> Markup:
112
+ """One line. `kind` is "gone" or "added" for a line that was removed or added as a whole."""
113
+ return el("div", *pieces, class_="ln" if kind is None else f"ln {kind}")
114
+
115
+
116
+ def _fold(count: int) -> Markup:
117
+ return el("div", f"{count} unchanged line{'' if count == 1 else 's'}", class_="fold")
118
+
119
+
120
+ def _split(before: str, after: str) -> tuple[list[str], list[str]]:
121
+ old = before.replace("\r\n", "\n").split("\n")
122
+ new = after.replace("\r\n", "\n").split("\n")
123
+ if len(old) > 1 and len(new) > 1 and old[-1] == "" and new[-1] == "": # both end with a line break: not a change
124
+ old.pop()
125
+ new.pop()
126
+ return old, new
127
+
128
+
129
+ def _pair(old_line: str, new_line: str) -> list[Markup]:
130
+ plan = _plan(old_line, new_line)
131
+ if plan is not None:
132
+ return [_line(*_inline_pieces(*plan))]
133
+ return [_line(*_marked(old_line, "rm"), kind="gone"), _line(*_marked(new_line, "ins"), kind="added")]
134
+
135
+
136
+ def redline(before: str, after: str) -> Markup | None:
137
+ """Return the changed text marked up, or None when it is too large to compare quickly."""
138
+ if len(before) > MAX_DOCUMENT_CHARS or len(after) > MAX_DOCUMENT_CHARS:
139
+ return None
140
+ old, new = _split(before, after)
141
+ if len(old) > MAX_DOCUMENT_LINES or len(new) > MAX_DOCUMENT_LINES:
142
+ return None
143
+ show_everything = len(before) <= MAX_REDLINE_CHARS and len(after) <= MAX_REDLINE_CHARS
144
+ ops = difflib.SequenceMatcher(None, old, new, autojunk=False).get_opcodes()
145
+ rows: list[Markup] = []
146
+ last = len(ops) - 1
147
+ for index, (tag, i1, i2, j1, j2) in enumerate(ops):
148
+ if tag == "equal":
149
+ lines = old[i1:i2]
150
+ keep_head = CONTEXT_LINES if index > 0 else 0
151
+ keep_tail = CONTEXT_LINES if index < last else 0
152
+ if show_everything or len(ops) == 1 or len(lines) <= keep_head + keep_tail + 1:
153
+ rows += [_line(block(text)) for text in lines]
154
+ continue
155
+ rows += [_line(block(text)) for text in lines[:keep_head]]
156
+ rows.append(_fold(len(lines) - keep_head - keep_tail))
157
+ rows += [_line(block(text)) for text in lines[len(lines) - keep_tail:]]
158
+ elif tag == "replace" and (i2 - i1) == (j2 - j1):
159
+ for text_old, text_new in zip(old[i1:i2], new[j1:j2], strict=True):
160
+ rows += _pair(text_old, text_new)
161
+ else:
162
+ rows += [_line(*_marked(text, "rm"), kind="gone") for text in old[i1:i2]]
163
+ rows += [_line(*_marked(text, "ins"), kind="added") for text in new[j1:j2]]
164
+ return el("div", *rows, class_="redline lines")
165
+
166
+
167
+ # -- a one-glance summary of the first change, for list rows ----------------------------------------------------
168
+
169
+ def _clip(text: str, limit: int, *, keep_end: bool) -> str:
170
+ """Shorten to roughly `limit` characters at a word boundary, marking the cut with an ellipsis."""
171
+ if len(text) <= limit:
172
+ return text
173
+ if keep_end:
174
+ cut = text[len(text) - limit:]
175
+ space = cut.find(" ")
176
+ return "\u2026" + (cut[space + 1:] if space != -1 else cut)
177
+ cut = text[:limit]
178
+ space = cut.rfind(" ")
179
+ return (cut[:space] if space != -1 else cut) + "\u2026"
180
+
181
+
182
+ def _words_snippet(plan: tuple[list[tuple], list[str], list[str]], context: int) -> tuple[Markup, int]:
183
+ ops, old_tokens, new_tokens = plan
184
+ changes = [i for i, op in enumerate(ops) if op[0] != "equal"]
185
+ index = changes[0]
186
+ _, i1, i2, j1, j2 = ops[index]
187
+ left = _clip("".join(old_tokens[:i1]), context, keep_end=True)
188
+ following = ops[index + 1] if index + 1 < len(ops) else None
189
+ right_tokens = old_tokens[following[1]:following[2]] if following is not None and following[0] == "equal" else []
190
+ right = _clip("".join(right_tokens), context, keep_end=False)
191
+ removed = _clip("".join(old_tokens[i1:i2]), 300, keep_end=False)
192
+ added = _clip("".join(new_tokens[j1:j2]), 300, keep_end=False)
193
+ pieces: list[Markup] = [block(left)] if left else []
194
+ pieces += _marked(removed, "rm") + _marked(added, "ins")
195
+ if right:
196
+ pieces.append(block(right))
197
+ return el("span", *pieces), len(changes) - 1
198
+
199
+
200
+ def change_snippet(before: str, after: str, context: int = 70) -> tuple[Markup, str | None] | None:
201
+ """The first change in a text, for a list row, and a short note about the rest ("3 lines changed").
202
+
203
+ This answers "what happened" at a glance: a long file that gained one sentence shows that sentence, not
204
+ the start of the file. A rewritten line shows what it says now. Line breaks never reach the result; it is
205
+ escaped like everything else."""
206
+ if len(before) > MAX_DOCUMENT_CHARS or len(after) > MAX_DOCUMENT_CHARS:
207
+ return None
208
+ old, new = _split(before, after)
209
+ if len(old) > MAX_DOCUMENT_LINES or len(new) > MAX_DOCUMENT_LINES:
210
+ return None
211
+ changed = [op for op in difflib.SequenceMatcher(None, old, new, autojunk=False).get_opcodes() if op[0] != "equal"]
212
+ if not changed:
213
+ return None
214
+ lines_changed = sum(max(i2 - i1, j2 - j1) for _, i1, i2, j1, j2 in changed)
215
+ _, i1, i2, j1, j2 = changed[0]
216
+ old_line = next((text for text in old[i1:i2] if text.strip()), None)
217
+ new_line = next((text for text in new[j1:j2] if text.strip()), None)
218
+
219
+ note: str | None = None
220
+ if old_line is not None and new_line is not None:
221
+ plan = _plan(old_line, new_line)
222
+ if plan is not None:
223
+ snippet, more = _words_snippet(plan, context)
224
+ if more and lines_changed == 1:
225
+ note = f"and {more} more change{'' if more == 1 else 's'}"
226
+ else: # a rewrite: show what it says now (and the old text too when that is short)
227
+ pieces: list[Markup] = []
228
+ if len(old_line) <= 80:
229
+ pieces += _marked(old_line.strip(), "rm") + [block(" ")]
230
+ pieces += _marked(_clip(new_line.strip(), 220, keep_end=False), "ins")
231
+ snippet = el("span", *pieces)
232
+ if len(old_line) > 80 and lines_changed == 1:
233
+ note = "rewritten"
234
+ elif new_line is not None:
235
+ snippet = el("span", *_marked(_clip(new_line.strip(), 220, keep_end=False), "ins"))
236
+ elif old_line is not None:
237
+ snippet = el("span", *_marked(_clip(old_line.strip(), 220, keep_end=False), "rm"))
238
+ else:
239
+ return None
240
+ if lines_changed >= 2:
241
+ note = f"{lines_changed} lines changed"
242
+ return snippet, note
@@ -0,0 +1,432 @@
1
+ """A local, read-only web server for the ledger.
2
+
3
+ Threat model: the page shows UNTRUSTED text (an attacker can plant anything in a memory), and it runs
4
+ on a machine where other programs, other users and web pages in your browser can all reach
5
+ 127.0.0.1. Defences, in the order a request meets them:
6
+
7
+ 1. The server listens on 127.0.0.1 only. On Windows it takes the port EXCLUSIVELY, because Windows
8
+ would otherwise let another program share the same port.
9
+ 2. At most 64 connections and 16 requests at once, and a 10 second limit per connection
10
+ (slow-connection attacks).
11
+ 3. Only GET and HEAD. Anything else is refused. Connections are never kept alive.
12
+ 4. The request line is checked: no absolute URLs, control characters, non-ASCII or oversized paths.
13
+ 5. The Host header must be exactly 127.0.0.1:PORT or localhost:PORT. This is what stops "DNS
14
+ rebinding", where a web page in your browser tricks the browser into talking to this server
15
+ under an attacker's hostname.
16
+ 6. Requests that the browser marks as coming from another site (Sec-Fetch-Site) or from another
17
+ origin (Origin) are refused.
18
+ 7. A random 192-bit secret, shown only in the link printed in your terminal, is required. It is
19
+ moved into an HttpOnly, SameSite=Strict cookie on first use and removed from the address.
20
+ 8. Query values are validated against strict patterns; anything unexpected is a 400.
21
+ 9. The ledger is opened read-only for each request: it can be neither changed nor upgraded here.
22
+ 10. Pages are built so untrusted text is always escaped and contain no scripts; the response
23
+ headers forbid scripts, inline styles, framing, caching and referrers anyway.
24
+ 11. Errors never show paths, tracebacks or request input. The log never contains the secret.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import hmac
29
+ import http.server
30
+ import logging
31
+ import os
32
+ import re
33
+ import secrets
34
+ import socket
35
+ import socketserver
36
+ import sqlite3
37
+ import threading
38
+ import time
39
+ from dataclasses import dataclass
40
+ from datetime import datetime, timezone
41
+ from pathlib import Path
42
+ from urllib.parse import parse_qs, urlencode, urlsplit
43
+
44
+ from ..errors import LedgerError, SnapshotError
45
+ from ..ledger import Ledger, VerifyResult
46
+ from ..models import Op, Trust
47
+ from ..textsafe import safe_text
48
+ from . import pages
49
+ from .style import CSS
50
+
51
+ logger = logging.getLogger("memdebug.viewer")
52
+
53
+ LOOPBACK = "127.0.0.1"
54
+ MAX_PATH = 2048
55
+ MAX_QUERY_FIELDS = 12
56
+ MAX_CONCURRENT = 16
57
+ MAX_BODY_BYTES = 8 * 1024 * 1024
58
+ VERIFY_CACHE_SECONDS = 10.0
59
+ REQUEST_TIMEOUT_SECONDS = 10
60
+ MAX_CONNECTIONS = 64
61
+ LEDGER_BUSY_TIMEOUT_SECONDS = 5.0
62
+
63
+ CSP = ("default-src 'none'; style-src 'self'; base-uri 'none'; form-action 'self'; "
64
+ "frame-ancestors 'none'; img-src 'none'; script-src 'none'")
65
+ SECURITY_HEADERS = (
66
+ ("Content-Security-Policy", CSP),
67
+ ("X-Content-Type-Options", "nosniff"),
68
+ ("X-Frame-Options", "DENY"),
69
+ ("Referrer-Policy", "no-referrer"),
70
+ ("Cache-Control", "no-store"),
71
+ ("Cross-Origin-Resource-Policy", "same-origin"),
72
+ ("Cross-Origin-Opener-Policy", "same-origin"),
73
+ ("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()"),
74
+ )
75
+
76
+ _ID_RE = {
77
+ "event": re.compile(r"^e[1-9][0-9]{0,11}\Z"),
78
+ "snapshot": re.compile(r"^s[1-9][0-9]{0,8}\Z"),
79
+ "number": re.compile(r"^[1-9][0-9]{0,11}\Z"),
80
+ "page": re.compile(r"^[1-9][0-9]{0,5}\Z"),
81
+ "flag": re.compile(r"^1\Z"),
82
+ }
83
+ _ROUTES = [
84
+ ("overview", re.compile(r"^/\Z")),
85
+ ("timeline", re.compile(r"^/timeline\Z")),
86
+ ("event", re.compile(r"^/event/(e[1-9][0-9]{0,11})\Z")),
87
+ ("snapshots", re.compile(r"^/snapshots\Z")),
88
+ ("snapshot", re.compile(r"^/snapshot/(s[1-9][0-9]{0,8})\Z")),
89
+ ("diff", re.compile(r"^/diff\Z")),
90
+ ("integrity", re.compile(r"^/integrity\Z")),
91
+ ("style", re.compile(r"^/style\.css\Z")),
92
+ ("theme", re.compile(r"^/theme\Z")),
93
+ ]
94
+ _NEXT_KEYS = frozenset({"op", "trust", "before", "event", "page", "from", "to", "full"}) # the only inputs a page reads
95
+ _NEXT_VALUE = re.compile(r"^[A-Za-z0-9_\-]{1,32}\Z")
96
+ _NEXT_TEXT = re.compile(r"^/(?!/)[A-Za-z0-9_\-./?=&%:~+,]{0,300}\Z")
97
+ _THEME_MODES = ("auto", "light", "dark")
98
+ THEME_MAX_AGE = 365 * 24 * 3600
99
+ _TOKEN_IN_TEXT = re.compile(r"token=[^&\s\"']*")
100
+
101
+
102
+ class BadRequest(Exception):
103
+ """Input that failed validation. The message is generic; request input is never echoed."""
104
+
105
+
106
+ @dataclass
107
+ class _VerifyCache:
108
+ result: VerifyResult
109
+ counts: dict
110
+ checked_at: datetime
111
+ taken: float
112
+
113
+
114
+ class ViewerState:
115
+ def __init__(self, ledger_path: Path, port: int, token: str):
116
+ self.ledger_path = ledger_path
117
+ self.token = token
118
+ self.token_bytes = token.encode("ascii")
119
+ self.allowed_hosts = {f"{LOOPBACK}:{port}", f"localhost:{port}"}
120
+ self.allowed_origins = {f"http://{host}" for host in self.allowed_hosts}
121
+ self.cookie_name = f"mdv_{port}"
122
+ self.theme_cookie_name = f"mdt_{port}"
123
+ self.slots = threading.BoundedSemaphore(MAX_CONCURRENT)
124
+ self.verify_lock = threading.Lock()
125
+ self.verify_cache: _VerifyCache | None = None
126
+ self.context = pages.Context(ledger_name=ledger_path.name)
127
+
128
+
129
+ class ViewerServer(http.server.ThreadingHTTPServer):
130
+ daemon_threads = True
131
+ allow_reuse_address = False # never share the port
132
+ request_queue_size = 16
133
+
134
+ def __init__(self, ledger_path: str | Path, port: int = 0, token: str | None = None):
135
+ path = Path(ledger_path)
136
+ Ledger.open_readonly(path).close() # fail early, before binding anything
137
+ if not (isinstance(port, int) and 0 <= port <= 65535):
138
+ raise ValueError("port must be between 0 and 65535")
139
+ self.token = token or secrets.token_urlsafe(24)
140
+ self._connection_slots = threading.BoundedSemaphore(MAX_CONNECTIONS)
141
+ super().__init__((LOOPBACK, port), _Handler)
142
+ self.state = ViewerState(path, self.server_address[1], self.token)
143
+
144
+ def process_request(self, request, client_address) -> None:
145
+ # One thread per connection would let idle connections pile up; beyond the cap, hang up at once.
146
+ if not self._connection_slots.acquire(blocking=False):
147
+ self.shutdown_request(request)
148
+ return
149
+ super().process_request(request, client_address)
150
+
151
+ def process_request_thread(self, request, client_address) -> None:
152
+ try:
153
+ super().process_request_thread(request, client_address)
154
+ finally:
155
+ self._connection_slots.release()
156
+
157
+ def server_bind(self) -> None:
158
+ if os.name == "nt" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
159
+ # Without this, Windows lets another program bind the same port and receive requests.
160
+ self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
161
+ socketserver.TCPServer.server_bind(self) # not HTTPServer's: it does a slow name lookup
162
+ self.server_name, self.server_port = LOOPBACK, self.socket.getsockname()[1]
163
+
164
+ @property
165
+ def port(self) -> int:
166
+ return self.server_address[1]
167
+
168
+ @property
169
+ def url(self) -> str:
170
+ return f"http://{LOOPBACK}:{self.port}/?token={self.token}"
171
+
172
+
173
+ class _Handler(http.server.BaseHTTPRequestHandler):
174
+ server_version = "memdebug-viewer"
175
+ sys_version = ""
176
+ protocol_version = "HTTP/1.0" # one request per connection
177
+ timeout = REQUEST_TIMEOUT_SECONDS
178
+
179
+ # -- logging that never records the secret or raw input ------------------------------------------------
180
+
181
+ def log_message(self, format: str, *args) -> None: # noqa: A002
182
+ text = _TOKEN_IN_TEXT.sub("token=...", format % args if args else format)
183
+ logger.info("%s", safe_text(text, 200))
184
+
185
+ def log_request(self, code="-", size="-") -> None:
186
+ path = (getattr(self, "path", "") or "").split("?", 1)[0]
187
+ logger.info("%s %s -> %s", safe_text(getattr(self, "command", "-"), 10), safe_text(path, 100), code)
188
+
189
+ # -- responses -----------------------------------------------------------------------------------------------
190
+
191
+ def _send(self, status: int, body: bytes, content_type: str, extra: tuple = ()) -> None:
192
+ self.send_response(status)
193
+ for name, value in SECURITY_HEADERS:
194
+ self.send_header(name, value)
195
+ for name, value in extra:
196
+ self.send_header(name, value)
197
+ self.send_header("Content-Type", content_type)
198
+ self.send_header("Content-Length", str(len(body)))
199
+ self.send_header("Connection", "close")
200
+ self.end_headers()
201
+ if getattr(self, "command", None) != "HEAD":
202
+ self.wfile.write(body)
203
+
204
+ def _text(self, status: int, message: str, extra: tuple = ()) -> None:
205
+ self._send(status, (message + "\n").encode("utf-8"), "text/plain; charset=utf-8", extra)
206
+
207
+ def send_error(self, code, message=None, explain=None) -> None: # replaces the default HTML page
208
+ self._text(code, f"Error {code}")
209
+
210
+ def _page(self, page: pages.Page) -> None:
211
+ body = page.html.encode("utf-8")
212
+ if len(body) > MAX_BODY_BYTES:
213
+ return self._text(500, "That page is too large to show.")
214
+ self._send(page.status, body, "text/html; charset=utf-8")
215
+
216
+ # -- methods -----------------------------------------------------------------------------------------------------
217
+
218
+ def do_GET(self) -> None:
219
+ self._dispatch()
220
+
221
+ do_HEAD = do_GET
222
+
223
+ def _refuse_method(self) -> None:
224
+ self._text(405, "Method not allowed. This viewer is read-only.", (("Allow", "GET, HEAD"),))
225
+
226
+ do_POST = do_PUT = do_DELETE = do_PATCH = do_OPTIONS = _refuse_method
227
+
228
+ def _dispatch(self) -> None:
229
+ state: ViewerState = self.server.state # type: ignore[attr-defined]
230
+ if not state.slots.acquire(blocking=False):
231
+ return self._text(503, "Busy. Try again in a moment.", (("Retry-After", "2"),))
232
+ try:
233
+ self._handle(state)
234
+ except (ConnectionError, TimeoutError): # includes Windows' ConnectionAbortedError
235
+ pass # the browser went away
236
+ except BadRequest:
237
+ self._text(400, "Bad request.")
238
+ except Exception as exc: # never show details; the class name is enough for the log
239
+ logger.error("unexpected %s while handling a request", type(exc).__name__)
240
+ try:
241
+ self._text(500, "Something went wrong.")
242
+ except OSError:
243
+ pass
244
+ finally:
245
+ state.slots.release()
246
+
247
+ # -- request checks ---------------------------------------------------------------------------------------------------
248
+
249
+ def _cookie(self, name: str) -> str | None:
250
+ for header in (self.headers.get_all("Cookie") or [])[:3]:
251
+ for part in header.split(";"):
252
+ key, _, value = part.strip().partition("=")
253
+ if key == name:
254
+ return value
255
+ return None
256
+
257
+ def _token_ok(self, supplied: str | None, state: ViewerState) -> bool:
258
+ if supplied is None:
259
+ return False
260
+ return hmac.compare_digest(supplied.encode("utf-8", "replace"), state.token_bytes)
261
+
262
+ def _handle(self, state: ViewerState) -> None:
263
+ raw = self.path
264
+ # Python may rewrite a leading "//" in self.path (and older versions do not), so the target is
265
+ # also read from the original request line: every version then answers the same way.
266
+ parts = self.requestline.split(" ")
267
+ original = parts[1] if len(parts) >= 3 else raw
268
+ if (len(raw) > MAX_PATH or not raw.startswith("/") or raw.startswith("//") or original.startswith("//")
269
+ or any(ord(c) < 33 or ord(c) > 126 for c in raw)):
270
+ raise BadRequest
271
+ hosts = self.headers.get_all("Host") or []
272
+ if len(hosts) != 1 or hosts[0].strip().lower() not in state.allowed_hosts:
273
+ return self._text(421, "Misdirected request.")
274
+ fetch_site = self.headers.get("Sec-Fetch-Site")
275
+ if fetch_site is not None and fetch_site.strip().lower() not in ("same-origin", "none"):
276
+ return self._text(403, "Cross-site requests are not allowed.")
277
+ origin = self.headers.get("Origin")
278
+ if origin is not None and origin.strip().lower() not in state.allowed_origins:
279
+ return self._text(403, "Cross-origin requests are not allowed.")
280
+ try:
281
+ split = urlsplit(raw)
282
+ query = parse_qs(split.query, keep_blank_values=False, max_num_fields=MAX_QUERY_FIELDS)
283
+ except ValueError:
284
+ raise BadRequest from None
285
+ path = split.path
286
+
287
+ token_in_url = query.pop("token", [None])[0]
288
+ if token_in_url is not None:
289
+ if not self._token_ok(token_in_url, state):
290
+ return self._text(403, "Access denied. Open the full link printed by 'memdebug serve'.")
291
+ return self._redirect_without_token(path, query, state)
292
+ if not self._token_ok(self._cookie(state.cookie_name), state):
293
+ return self._text(403, "Access denied. Open the full link printed by 'memdebug serve'.")
294
+
295
+ self.ctx = self._request_context(state, path, query)
296
+ route = next(((name, m) for name, rx in _ROUTES if (m := rx.match(path))), None)
297
+ if route is None:
298
+ return self._page(pages.error_page(self.ctx, 404, "Not found", "There is nothing at that address."))
299
+ self._route(route[0], route[1], query, state)
300
+
301
+ @staticmethod
302
+ def _local_target(path: str, query: dict) -> str:
303
+ """A page address rebuilt from parts that are known to be safe: a real page, and only the inputs that page
304
+ reads, each matching a strict pattern. Anything else is dropped, so this can never point elsewhere."""
305
+ if not any(rx.match(path) for name, rx in _ROUTES if name not in ("theme", "style")):
306
+ return "/"
307
+ keep = {k: v[0] for k, v in query.items() if k in _NEXT_KEYS and v and _NEXT_VALUE.match(v[0])}
308
+ return pages.url(path, **keep)
309
+
310
+ def _request_context(self, state: ViewerState, path: str, query: dict) -> pages.Context:
311
+ chosen = self._cookie(state.theme_cookie_name)
312
+ return pages.Context(ledger_name=state.context.ledger_name, theme=chosen if chosen in ("light", "dark") else "auto",
313
+ here=self._local_target(path, query))
314
+
315
+ def _set_theme(self, query: dict, state: ViewerState) -> None:
316
+ modes = query.get("mode") or []
317
+ if len(modes) != 1 or modes[0] not in _THEME_MODES:
318
+ raise BadRequest
319
+ target = "/"
320
+ supplied = (query.get("next") or [None])[0]
321
+ if supplied is not None and _NEXT_TEXT.match(supplied):
322
+ try:
323
+ where = urlsplit(supplied)
324
+ target = self._local_target(where.path, parse_qs(where.query, max_num_fields=MAX_QUERY_FIELDS))
325
+ except ValueError:
326
+ target = "/"
327
+ name = state.theme_cookie_name
328
+ cookie = (f"{name}=; Max-Age=0; HttpOnly; SameSite=Strict; Path=/" if modes[0] == "auto"
329
+ else f"{name}={modes[0]}; Max-Age={THEME_MAX_AGE}; HttpOnly; SameSite=Strict; Path=/")
330
+ self._send(303, b"", "text/plain; charset=utf-8", (("Location", target), ("Set-Cookie", cookie)))
331
+
332
+ def _redirect_without_token(self, path: str, query: dict, state: ViewerState) -> None:
333
+ known = any(rx.match(path) for _, rx in _ROUTES)
334
+ target = path if known else "/"
335
+ remaining = [(k, v) for k, vals in query.items() for v in vals[:1]] if known else []
336
+ location = target + (f"?{urlencode(remaining)}" if remaining else "")
337
+ cookie = f"{state.cookie_name}={state.token}; HttpOnly; SameSite=Strict; Path=/"
338
+ self._send(303, b"", "text/plain; charset=utf-8", (("Location", location), ("Set-Cookie", cookie)))
339
+
340
+ # -- routing and input validation -----------------------------------------------------------------------------------------
341
+
342
+ @staticmethod
343
+ def _param(query: dict, name: str, kind: str) -> str | None:
344
+ values = query.get(name)
345
+ if not values:
346
+ return None
347
+ if len(values) != 1 or not _ID_RE[kind].match(values[0]):
348
+ raise BadRequest
349
+ return values[0]
350
+
351
+ def _route(self, name: str, match: re.Match, query: dict, state: ViewerState) -> None:
352
+ if name == "style":
353
+ return self._send(200, CSS.encode("utf-8"), "text/css; charset=utf-8")
354
+ if name == "theme":
355
+ return self._set_theme(query, state)
356
+ if name == "event":
357
+ target = pages.url("/timeline", event=match.group(1))
358
+ return self._send(303, b"", "text/plain; charset=utf-8", (("Location", target),))
359
+ op = trust = None
360
+ if name == "timeline":
361
+ op = (query.get("op") or [None])[0]
362
+ trust = (query.get("trust") or [None])[0]
363
+ if (len(query.get("op", [])) > 1 or len(query.get("trust", [])) > 1
364
+ or (op is not None and op not in {o.value for o in Op})
365
+ or (trust is not None and trust not in {t.value for t in Trust})):
366
+ raise BadRequest
367
+ before = self._param(query, "before", "number") if name == "timeline" else None
368
+ event = self._param(query, "event", "event") if name == "timeline" else None
369
+ page = self._param(query, "page", "page") if name == "snapshot" else None
370
+ old = self._param(query, "from", "snapshot") if name == "diff" else None
371
+ new = self._param(query, "to", "snapshot") if name == "diff" else None
372
+ full = self._param(query, "full", "flag") == "1" if name == "diff" else False
373
+
374
+ try:
375
+ ledger = Ledger.open_readonly(state.ledger_path, busy_timeout=LEDGER_BUSY_TIMEOUT_SECONDS)
376
+ except LedgerError as exc:
377
+ return self._page(pages.error_page(
378
+ self.ctx, 503, "The ledger cannot be read right now",
379
+ f"{exc} If a sync is running, wait a moment and reload."))
380
+ try:
381
+ result = self._render(name, ledger, state, op=op, trust=trust, before=before and int(before),
382
+ event=event, page=int(page or 1), old=old, new=new, full=full,
383
+ snapshot_id=match.group(1) if name == "snapshot" else None)
384
+ except SnapshotError as exc:
385
+ missing = "does not exist" in str(exc)
386
+ result = pages.error_page(
387
+ self.ctx, 404 if missing else 500, "Snapshot not found" if missing else "Integrity problem", str(exc))
388
+ except LedgerError as exc:
389
+ result = pages.error_page(self.ctx, 500, "The ledger could not be read", str(exc))
390
+ except sqlite3.Error:
391
+ result = pages.error_page(self.ctx, 503, "The ledger is busy", "Reload in a moment.")
392
+ finally:
393
+ ledger.close()
394
+ self._page(result)
395
+
396
+ def _render(self, name, ledger: Ledger, state: ViewerState, **p) -> pages.Page:
397
+ ctx = self.ctx
398
+ if name == "overview":
399
+ return pages.overview(ledger, ctx)
400
+ if name == "timeline":
401
+ return pages.timeline(ledger, ctx, op=p["op"], trust=p["trust"], before=p["before"], event_id=p["event"])
402
+ if name == "snapshots":
403
+ return pages.snapshots(ledger, ctx)
404
+ if name == "snapshot":
405
+ return pages.snapshot_detail(ledger, ctx, p["snapshot_id"], p["page"])
406
+ if name == "diff":
407
+ return pages.diff_page(ledger, ctx, p["old"], p["new"], p["full"])
408
+ return self._integrity(ledger, state)
409
+
410
+ def _integrity(self, ledger: Ledger, state: ViewerState) -> pages.Page:
411
+ with state.verify_lock: # one full check at a time; a recent result is reused
412
+ cached = state.verify_cache
413
+ if cached is None or time.monotonic() - cached.taken > VERIFY_CACHE_SECONDS:
414
+ cached = _VerifyCache(ledger.verify(), ledger.counts(), datetime.now(timezone.utc), time.monotonic())
415
+ state.verify_cache = cached
416
+ return pages.integrity(self.ctx, cached.counts, cached.result, cached.checked_at)
417
+
418
+
419
+ def serve(ledger_path: str | Path, port: int, announce, open_browser: bool = False) -> None:
420
+ """Run until interrupted. `announce(url)` is called once the server is listening."""
421
+ server = ViewerServer(ledger_path, port)
422
+ try:
423
+ announce(server.url)
424
+ if open_browser:
425
+ import webbrowser
426
+
427
+ webbrowser.open(server.url)
428
+ server.serve_forever()
429
+ except KeyboardInterrupt:
430
+ pass
431
+ finally:
432
+ server.server_close()