termwright 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.
- termwright/__init__.py +125 -0
- termwright/client.py +708 -0
- termwright/debug.py +169 -0
- termwright/diffing.py +118 -0
- termwright/errors.py +20 -0
- termwright/framing.py +196 -0
- termwright/limits.py +82 -0
- termwright/logging_bridge.py +108 -0
- termwright/logs.py +207 -0
- termwright/marker.py +128 -0
- termwright/messages.py +417 -0
- termwright/roles.py +57 -0
- termwright/textual.py +249 -0
- termwright/tree.py +282 -0
- termwright/validate.py +846 -0
- termwright-0.2.0.dist-info/METADATA +336 -0
- termwright-0.2.0.dist-info/RECORD +25 -0
- termwright-0.2.0.dist-info/WHEEL +4 -0
- termwright_probe/__init__.py +73 -0
- termwright_probe/__main__.py +45 -0
- termwright_probe/bootstrap.py +207 -0
- termwright_probe/defer.py +130 -0
- termwright_probe/session.py +197 -0
- termwright_probe/textual_probe.py +239 -0
- termwright_probe/textual_tree.py +634 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Bridge from the standard library's :mod:`logging` to the semantic channel.
|
|
2
|
+
|
|
3
|
+
A TUI must not write diagnostics to the screen, so the usual advice is to send
|
|
4
|
+
them to a file. Under the driver they can go somewhere better: attach this
|
|
5
|
+
handler and every record the application already emits becomes assertable test
|
|
6
|
+
state, with the application's own logging calls unchanged.
|
|
7
|
+
|
|
8
|
+
from termwright import client_from_env
|
|
9
|
+
from termwright.logging_bridge import install_log_handler
|
|
10
|
+
|
|
11
|
+
client = client_from_env(adapter_name="my-tui", adapter_version="1.0.0",
|
|
12
|
+
capabilities=CAPABILITIES_WITH_LOGS)
|
|
13
|
+
if client is not None and await client.start():
|
|
14
|
+
install_log_handler(client)
|
|
15
|
+
|
|
16
|
+
Dormant by construction: with no client there is no handler, so an app that
|
|
17
|
+
ships this call unconditionally still logs exactly as it did before.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from typing import Any, Dict, Optional
|
|
24
|
+
|
|
25
|
+
from .client import SemanticClient
|
|
26
|
+
from .logs import flatten_attrs
|
|
27
|
+
|
|
28
|
+
#: Python's ladder onto the wire's. `logging` has no `trace` or `fatal` of its
|
|
29
|
+
#: own: anything below DEBUG is trace, and CRITICAL is fatal.
|
|
30
|
+
_LEVEL_BY_NUMBER = (
|
|
31
|
+
(logging.CRITICAL, "fatal"),
|
|
32
|
+
(logging.ERROR, "error"),
|
|
33
|
+
(logging.WARNING, "warn"),
|
|
34
|
+
(logging.INFO, "info"),
|
|
35
|
+
(logging.DEBUG, "debug"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
#: Attributes every :class:`logging.LogRecord` carries; anything else on the
|
|
39
|
+
#: record was put there by the application and is worth forwarding.
|
|
40
|
+
_STANDARD_FIELDS = frozenset(
|
|
41
|
+
{
|
|
42
|
+
"args", "asctime", "created", "exc_info", "exc_text", "filename",
|
|
43
|
+
"funcName", "levelname", "levelno", "lineno", "message", "module",
|
|
44
|
+
"msecs", "msg", "name", "pathname", "process", "processName",
|
|
45
|
+
"relativeCreated", "stack_info", "taskName", "thread", "threadName",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def level_for(levelno: int) -> str:
|
|
51
|
+
"""Map a :mod:`logging` level number onto a wire level."""
|
|
52
|
+
for threshold, name in _LEVEL_BY_NUMBER:
|
|
53
|
+
if levelno >= threshold:
|
|
54
|
+
return name
|
|
55
|
+
return "trace"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TermwrightLogHandler(logging.Handler):
|
|
59
|
+
"""A :class:`logging.Handler` that forwards records to the driver.
|
|
60
|
+
|
|
61
|
+
Never raises into the application: a handler that threw would turn a log
|
|
62
|
+
line into a crash, so failures are counted on the client and dropped.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, client: SemanticClient, level: int = logging.NOTSET) -> None:
|
|
66
|
+
super().__init__(level)
|
|
67
|
+
self._client = client
|
|
68
|
+
|
|
69
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
70
|
+
try:
|
|
71
|
+
self._client.log(
|
|
72
|
+
level_for(record.levelno),
|
|
73
|
+
self.format(record) if self.formatter else record.getMessage(),
|
|
74
|
+
attrs=self._attrs(record),
|
|
75
|
+
logger=record.name,
|
|
76
|
+
ts=int(record.created * 1000),
|
|
77
|
+
)
|
|
78
|
+
except Exception: # pragma: no cover - logging must never break the app
|
|
79
|
+
self.handleError(record)
|
|
80
|
+
|
|
81
|
+
def _attrs(self, record: logging.LogRecord) -> Optional[Dict[str, Any]]:
|
|
82
|
+
"""Application-supplied `extra` fields, flattened to dotted keys."""
|
|
83
|
+
extra = {
|
|
84
|
+
key: value
|
|
85
|
+
for key, value in record.__dict__.items()
|
|
86
|
+
if key not in _STANDARD_FIELDS and not key.startswith("_")
|
|
87
|
+
}
|
|
88
|
+
if record.exc_info and record.exc_info[0] is not None:
|
|
89
|
+
extra["exception"] = record.exc_info[0].__name__
|
|
90
|
+
return flatten_attrs(extra) if extra else None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def install_log_handler(
|
|
94
|
+
client: Optional[SemanticClient],
|
|
95
|
+
logger: Optional[logging.Logger] = None,
|
|
96
|
+
level: int = logging.NOTSET,
|
|
97
|
+
) -> Optional[TermwrightLogHandler]:
|
|
98
|
+
"""Attach a handler to ``logger`` (the root logger by default).
|
|
99
|
+
|
|
100
|
+
:param client: A live client, or ``None`` — in which case nothing is
|
|
101
|
+
installed and ``None`` comes back, so the dormant path stays a no-op.
|
|
102
|
+
:returns: The installed handler, for later :meth:`logging.Logger.removeHandler`.
|
|
103
|
+
"""
|
|
104
|
+
if client is None:
|
|
105
|
+
return None
|
|
106
|
+
handler = TermwrightLogHandler(client, level)
|
|
107
|
+
(logger or logging.getLogger()).addHandler(handler)
|
|
108
|
+
return handler
|
termwright/logs.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Application log records carried over the semantic channel.
|
|
2
|
+
|
|
3
|
+
A TUI cannot print diagnostics to the screen without corrupting the render, so
|
|
4
|
+
applications write them to a logger instead. The ``logs`` capability forwards
|
|
5
|
+
those records to the driver, where they become assertable test state rather
|
|
6
|
+
than invisible side effects.
|
|
7
|
+
|
|
8
|
+
Records are bounded exactly like snapshots: checked against a byte ceiling and
|
|
9
|
+
rejected wholesale on any violation, so a misbehaving logger degrades into
|
|
10
|
+
dropped records rather than unbounded driver memory.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Dict, Mapping, Optional, Union
|
|
18
|
+
|
|
19
|
+
from .framing import encode_json, project_dto
|
|
20
|
+
from .errors import ProtocolViolation
|
|
21
|
+
from .limits import DEFAULT_LIMITS, ProtocolLimits
|
|
22
|
+
|
|
23
|
+
#: Severity ladder, least to most severe. The intersection of the ladders used
|
|
24
|
+
#: by Python ``logging``, Go ``slog``, Rust ``tracing``, pino and winston, so
|
|
25
|
+
#: every bridge maps onto it without inventing a level.
|
|
26
|
+
LOG_LEVELS = ("trace", "debug", "info", "warn", "error", "fatal")
|
|
27
|
+
|
|
28
|
+
LEVEL_SET = frozenset(LOG_LEVELS)
|
|
29
|
+
|
|
30
|
+
#: Numeric severity; higher is more severe.
|
|
31
|
+
LOG_LEVEL_SEVERITY: Dict[str, int] = {
|
|
32
|
+
"trace": 10,
|
|
33
|
+
"debug": 20,
|
|
34
|
+
"info": 30,
|
|
35
|
+
"warn": 40,
|
|
36
|
+
"error": 50,
|
|
37
|
+
"fatal": 60,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#: Maximum number of attribute keys on one record.
|
|
41
|
+
MAX_LOG_ATTRS = 64
|
|
42
|
+
|
|
43
|
+
RECORD_FIELDS = ("ts", "level", "message", "attrs", "logger", "seq", "revision")
|
|
44
|
+
|
|
45
|
+
_MAX_SAFE_INTEGER = 2**53 - 1
|
|
46
|
+
|
|
47
|
+
#: Values an attribute may hold. Scalars only: nested objects make record size
|
|
48
|
+
#: unbounded and depth-dependent, and every bridge already flattens for its own
|
|
49
|
+
#: transport.
|
|
50
|
+
LogAttrValue = Union[str, int, float, bool, None]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class LogRecord:
|
|
55
|
+
"""One application log record.
|
|
56
|
+
|
|
57
|
+
``ts`` is Unix epoch milliseconds, not session-relative: an adapter has no
|
|
58
|
+
reliable view of when the driver considers the session to have started, so
|
|
59
|
+
the wall clock is the only clock both sides agree on without negotiating.
|
|
60
|
+
The driver rebases it onto the session timeline.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
ts: int
|
|
64
|
+
level: str
|
|
65
|
+
message: str
|
|
66
|
+
seq: int
|
|
67
|
+
attrs: Optional[Mapping[str, LogAttrValue]] = None
|
|
68
|
+
logger: Optional[str] = None
|
|
69
|
+
revision: Optional[int] = None
|
|
70
|
+
|
|
71
|
+
def to_wire(self) -> Dict[str, Any]:
|
|
72
|
+
"""Serialise, dropping unset optionals: the schema is strict."""
|
|
73
|
+
wire: Dict[str, Any] = {"ts": self.ts, "level": self.level, "message": self.message}
|
|
74
|
+
if self.attrs is not None:
|
|
75
|
+
wire["attrs"] = dict(self.attrs)
|
|
76
|
+
if self.logger is not None:
|
|
77
|
+
wire["logger"] = self.logger
|
|
78
|
+
wire["seq"] = self.seq
|
|
79
|
+
if self.revision is not None:
|
|
80
|
+
wire["revision"] = self.revision
|
|
81
|
+
return wire
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class LogValidationResult:
|
|
86
|
+
"""Outcome of :func:`validate_log_record`."""
|
|
87
|
+
|
|
88
|
+
ok: bool
|
|
89
|
+
record: Optional[Dict[str, Any]] = None
|
|
90
|
+
code: Optional[str] = None
|
|
91
|
+
detail: str = ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _fail(code: str, detail: str) -> LogValidationResult:
|
|
95
|
+
return LogValidationResult(ok=False, code=code, detail=detail)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _utf8_len(text: str) -> int:
|
|
99
|
+
return len(text.encode("utf-8", "surrogatepass"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _safe_non_negative(value: Any) -> bool:
|
|
103
|
+
return (
|
|
104
|
+
isinstance(value, int)
|
|
105
|
+
and not isinstance(value, bool)
|
|
106
|
+
and 0 <= value <= _MAX_SAFE_INTEGER
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def validate_log_record(
|
|
111
|
+
value: Any, limits: ProtocolLimits = DEFAULT_LIMITS
|
|
112
|
+
) -> LogValidationResult:
|
|
113
|
+
"""Validate an untrusted log record against ``limits``.
|
|
114
|
+
|
|
115
|
+
Mirrors :func:`termwright.validate.validate_snapshot`: the value is
|
|
116
|
+
projected first, then measured against ``maxLogRecordBytes``, then checked
|
|
117
|
+
field by field. Never raises.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
projected = project_dto(value, limits.maxDepth)
|
|
121
|
+
except ProtocolViolation as error:
|
|
122
|
+
return _fail("depth" if error.code == "dto-depth" else "schema", str(error))
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
serialised = encode_json(projected)
|
|
126
|
+
except ProtocolViolation:
|
|
127
|
+
return _fail("schema", "log record is not JSON-serialisable")
|
|
128
|
+
if len(serialised) > limits.maxLogRecordBytes:
|
|
129
|
+
return _fail(
|
|
130
|
+
"bytes",
|
|
131
|
+
f"log record is {len(serialised)} bytes, ceiling is {limits.maxLogRecordBytes}",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if not isinstance(projected, dict):
|
|
135
|
+
return _fail("schema", "log record must be an object")
|
|
136
|
+
record: Dict[str, Any] = projected
|
|
137
|
+
|
|
138
|
+
for key in record:
|
|
139
|
+
if key not in RECORD_FIELDS:
|
|
140
|
+
return _fail("schema", f'unknown log record property "{key}"')
|
|
141
|
+
|
|
142
|
+
if not _safe_non_negative(record.get("ts")) or record["ts"] == 0:
|
|
143
|
+
return _fail("schema", "ts must be a positive safe integer (epoch milliseconds)")
|
|
144
|
+
if record.get("level") not in LEVEL_SET:
|
|
145
|
+
return _fail("schema", f"level must be one of {', '.join(LOG_LEVELS)}")
|
|
146
|
+
if not isinstance(record.get("message"), str):
|
|
147
|
+
return _fail("schema", "message must be a string")
|
|
148
|
+
if _utf8_len(record["message"]) > limits.maxStringBytes:
|
|
149
|
+
return _fail("string-bytes", f"message exceeds {limits.maxStringBytes} UTF-8 bytes")
|
|
150
|
+
if not _safe_non_negative(record.get("seq")):
|
|
151
|
+
return _fail("schema", "seq must be a non-negative safe integer")
|
|
152
|
+
|
|
153
|
+
if "logger" in record:
|
|
154
|
+
if not isinstance(record["logger"], str):
|
|
155
|
+
return _fail("schema", "logger must be a string")
|
|
156
|
+
if _utf8_len(record["logger"]) > limits.maxStringBytes:
|
|
157
|
+
return _fail("string-bytes", f"logger exceeds {limits.maxStringBytes} UTF-8 bytes")
|
|
158
|
+
|
|
159
|
+
if "revision" in record:
|
|
160
|
+
if not _safe_non_negative(record["revision"]) or record["revision"] == 0:
|
|
161
|
+
return _fail("revision", "revision must be a positive safe integer")
|
|
162
|
+
|
|
163
|
+
if "attrs" in record:
|
|
164
|
+
attrs = record["attrs"]
|
|
165
|
+
if not isinstance(attrs, dict):
|
|
166
|
+
return _fail("schema", "attrs must be a flat object")
|
|
167
|
+
if len(attrs) > MAX_LOG_ATTRS:
|
|
168
|
+
return _fail("count", f"attrs carries {len(attrs)} keys, ceiling is {MAX_LOG_ATTRS}")
|
|
169
|
+
for key, attr in attrs.items():
|
|
170
|
+
if _utf8_len(key) > limits.maxStringBytes:
|
|
171
|
+
return _fail("string-bytes", f'attribute key "{key}" exceeds the string ceiling')
|
|
172
|
+
if attr is not None and not isinstance(attr, (str, int, float, bool)):
|
|
173
|
+
return _fail(
|
|
174
|
+
"schema", f'attribute "{key}" must be a string, number, boolean or null'
|
|
175
|
+
)
|
|
176
|
+
if isinstance(attr, float) and (attr != attr or attr in (float("inf"), float("-inf"))):
|
|
177
|
+
return _fail("schema", f'attribute "{key}" must be a finite number')
|
|
178
|
+
if isinstance(attr, str) and _utf8_len(attr) > limits.maxStringBytes:
|
|
179
|
+
return _fail("string-bytes", f'attribute "{key}" exceeds the string ceiling')
|
|
180
|
+
|
|
181
|
+
return LogValidationResult(ok=True, record=record)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def flatten_attrs(
|
|
185
|
+
value: Mapping[str, Any], prefix: str = "", depth: int = 0
|
|
186
|
+
) -> Dict[str, LogAttrValue]:
|
|
187
|
+
"""Flatten nested context into dotted keys, as the wire format requires.
|
|
188
|
+
|
|
189
|
+
``{"db": {"host": "x"}}`` becomes ``{"db.host": "x"}``. Values that are not
|
|
190
|
+
scalars after flattening are rendered with :func:`repr`, because losing the
|
|
191
|
+
shape of a value is better than dropping the record that carries it.
|
|
192
|
+
"""
|
|
193
|
+
flat: Dict[str, LogAttrValue] = {}
|
|
194
|
+
for key, item in value.items():
|
|
195
|
+
name = f"{prefix}{key}"
|
|
196
|
+
if isinstance(item, Mapping) and depth < 4:
|
|
197
|
+
flat.update(flatten_attrs(item, f"{name}.", depth + 1))
|
|
198
|
+
elif item is None or isinstance(item, (str, bool, int)):
|
|
199
|
+
flat[name] = item
|
|
200
|
+
elif isinstance(item, float):
|
|
201
|
+
flat[name] = item if item == item and item not in (float("inf"), float("-inf")) else str(item)
|
|
202
|
+
else:
|
|
203
|
+
try:
|
|
204
|
+
flat[name] = json.dumps(item, default=repr)
|
|
205
|
+
except (TypeError, ValueError):
|
|
206
|
+
flat[name] = repr(item)
|
|
207
|
+
return flat
|
termwright/marker.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Render-commit marker.
|
|
2
|
+
|
|
3
|
+
The adapter writes this OSC sequence to stdout *after* the last byte of the
|
|
4
|
+
render belonging to revision N. It is a frame-commit signal, never a data
|
|
5
|
+
carrier::
|
|
6
|
+
|
|
7
|
+
OSC 8487 ; twm;<revision>;<mac> BEL
|
|
8
|
+
|
|
9
|
+
with ``mac = base64url(HMAC-SHA256(token, f"{session_id}:{revision}"))[:16]``,
|
|
10
|
+
unpadded. The token is an opaque UTF-8 string end to end: whatever arrives in
|
|
11
|
+
``TERMWRIGHT_TOKEN`` is fed to the HMAC as key bytes, never re-decoded.
|
|
12
|
+
|
|
13
|
+
OSC rather than DCS because ConPTY rewrites the stream it forwards: a
|
|
14
|
+
passthrough probe showed it dropping DCS, APC and OSC 8 while passing private
|
|
15
|
+
OSC with either terminator, so a DCS marker could not reach the driver on
|
|
16
|
+
Windows at all. One encoding everywhere beats negotiating per platform — the
|
|
17
|
+
path used least is the one that rots unnoticed.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import hmac
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from hashlib import sha256
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
from .errors import ProtocolViolation
|
|
30
|
+
|
|
31
|
+
#: The private OSC number carrying render-commit markers. Chosen clear of
|
|
32
|
+
#: everything in use (xterm's allocations, OSC 8, 9, 99, 133, 633, 697, 777+):
|
|
33
|
+
#: 84 and 87 are the ASCII codes of ``T`` and ``W``, for termwright.
|
|
34
|
+
MARKER_OSC_CODE = 8487
|
|
35
|
+
|
|
36
|
+
#: The tag opening a marker payload, immediately after ``OSC 8487;``. Kept as a
|
|
37
|
+
#: self-identifying guard: if anything ever claims 8487, a marker still says
|
|
38
|
+
#: what it is instead of being mistaken for that feature's payload.
|
|
39
|
+
MARKER_OSC_PREFIX = "twm;"
|
|
40
|
+
|
|
41
|
+
#: The terminator this implementation emits — the one ConPTY was observed to
|
|
42
|
+
#: forward most reliably.
|
|
43
|
+
BEL = "\x07"
|
|
44
|
+
|
|
45
|
+
#: The terminator a receiver must also accept.
|
|
46
|
+
ST = "\x1b\\"
|
|
47
|
+
MARKER_MAC_BYTES = 16
|
|
48
|
+
MARKER_MAC_CHARS = 22
|
|
49
|
+
|
|
50
|
+
_MAX_SAFE_INTEGER = 2**53 - 1
|
|
51
|
+
|
|
52
|
+
_REVISION_TEXT = re.compile(r"^[1-9][0-9]{0,15}$")
|
|
53
|
+
_MAC_TEXT = re.compile(r"^[A-Za-z0-9_-]{%d}$" % MARKER_MAC_CHARS)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class RenderMarker:
|
|
58
|
+
"""A verified marker: the revision it commits and the MAC that proved it."""
|
|
59
|
+
|
|
60
|
+
revision: int
|
|
61
|
+
mac: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def compute_mac(token: str, session_id: str, revision: int) -> str:
|
|
65
|
+
"""Return the unpadded base64url MAC bound to ``session_id`` and ``revision``."""
|
|
66
|
+
digest = hmac.new(
|
|
67
|
+
token.encode("utf-8"), f"{session_id}:{revision}".encode("utf-8"), sha256
|
|
68
|
+
).digest()
|
|
69
|
+
return base64.urlsafe_b64encode(digest[:MARKER_MAC_BYTES]).decode("ascii").rstrip("=")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def encode_marker(token: str, session_id: str, revision: int) -> str:
|
|
73
|
+
"""Build the full escape sequence committing ``revision``.
|
|
74
|
+
|
|
75
|
+
:raises ProtocolViolation: On an empty token/session id or a revision that
|
|
76
|
+
is not a positive safe integer.
|
|
77
|
+
"""
|
|
78
|
+
if not token:
|
|
79
|
+
raise ProtocolViolation("marker-argument", "token must not be empty")
|
|
80
|
+
if not session_id:
|
|
81
|
+
raise ProtocolViolation("marker-argument", "sessionId must not be empty")
|
|
82
|
+
if isinstance(revision, bool) or not isinstance(revision, int):
|
|
83
|
+
raise ProtocolViolation("marker-argument", "revision must be a positive safe integer")
|
|
84
|
+
if revision <= 0 or revision > _MAX_SAFE_INTEGER:
|
|
85
|
+
raise ProtocolViolation("marker-argument", "revision must be a positive safe integer")
|
|
86
|
+
mac = compute_mac(token, session_id, revision)
|
|
87
|
+
return f"\x1b]{MARKER_OSC_CODE};{MARKER_OSC_PREFIX}{revision};{mac}{BEL}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def verify_marker_payload(payload: str, token: str, session_id: str) -> Optional[RenderMarker]:
|
|
91
|
+
"""Parse and verify an OSC payload — everything after ``OSC 8487;``.
|
|
92
|
+
|
|
93
|
+
Total function: hostile payloads return ``None``, never raise. Only
|
|
94
|
+
canonically formatted revisions are accepted, so ``1`` and ``01`` cannot
|
|
95
|
+
both authenticate the same commit, and the MAC compare is constant-time.
|
|
96
|
+
|
|
97
|
+
A trailing BEL or ST is tolerated: a VT parser consumes the terminator
|
|
98
|
+
before dispatching, so a handler normally passes a payload without one,
|
|
99
|
+
while a caller scanning raw output with a regex keeps it. Both must work.
|
|
100
|
+
"""
|
|
101
|
+
if not token or not session_id:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
text = payload
|
|
105
|
+
if text.endswith(BEL):
|
|
106
|
+
text = text[: -len(BEL)]
|
|
107
|
+
elif text.endswith(ST):
|
|
108
|
+
text = text[: -len(ST)]
|
|
109
|
+
|
|
110
|
+
if not text.startswith(MARKER_OSC_PREFIX):
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
body = text[len(MARKER_OSC_PREFIX) :]
|
|
114
|
+
separator = body.find(";")
|
|
115
|
+
if separator < 0:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
revision_text = body[:separator]
|
|
119
|
+
mac = body[separator + 1 :]
|
|
120
|
+
if not _REVISION_TEXT.match(revision_text) or not _MAC_TEXT.match(mac):
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
revision = int(revision_text)
|
|
124
|
+
if revision <= 0 or revision > _MAX_SAFE_INTEGER:
|
|
125
|
+
return None
|
|
126
|
+
if not hmac.compare_digest(compute_mac(token, session_id, revision), mac):
|
|
127
|
+
return None
|
|
128
|
+
return RenderMarker(revision=revision, mac=mac)
|