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.
- memdebug/__init__.py +3 -0
- memdebug/__main__.py +3 -0
- memdebug/adapters/__init__.py +0 -0
- memdebug/adapters/base.py +37 -0
- memdebug/adapters/common.py +69 -0
- memdebug/adapters/folder.py +96 -0
- memdebug/adapters/markdown_git.py +731 -0
- memdebug/adapters/mem0.py +324 -0
- memdebug/adapters/openwebui.py +209 -0
- memdebug/adapters/restore.py +705 -0
- memdebug/agents.py +128 -0
- memdebug/backends.py +29 -0
- memdebug/cli.py +857 -0
- memdebug/demo.py +190 -0
- memdebug/describe.py +34 -0
- memdebug/diff.py +91 -0
- memdebug/docker_source.py +164 -0
- memdebug/errors.py +29 -0
- memdebug/hints.py +145 -0
- memdebug/ledger.py +700 -0
- memdebug/models.py +157 -0
- memdebug/monitor.py +246 -0
- memdebug/paths.py +19 -0
- memdebug/reconcile.py +83 -0
- memdebug/report.py +279 -0
- memdebug/rollback_flow.py +69 -0
- memdebug/selftest.py +482 -0
- memdebug/stores.py +276 -0
- memdebug/sync.py +177 -0
- memdebug/textsafe.py +73 -0
- memdebug/viewer/__init__.py +1 -0
- memdebug/viewer/html.py +136 -0
- memdebug/viewer/pages.py +569 -0
- memdebug/viewer/redline.py +242 -0
- memdebug/viewer/server.py +432 -0
- memdebug/viewer/style.py +218 -0
- memdebug/witness.py +199 -0
- memdebug-0.2.0.dist-info/METADATA +206 -0
- memdebug-0.2.0.dist-info/RECORD +43 -0
- memdebug-0.2.0.dist-info/WHEEL +4 -0
- memdebug-0.2.0.dist-info/entry_points.txt +2 -0
- memdebug-0.2.0.dist-info/licenses/LICENSE +202 -0
- memdebug-0.2.0.dist-info/licenses/NOTICE +4 -0
|
@@ -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()
|