plimsoll-client 0.19.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.
- plimsoll_client/__init__.py +116 -0
- plimsoll_client/_record.py +300 -0
- plimsoll_client/_transport.py +149 -0
- plimsoll_client/_validate.py +388 -0
- plimsoll_client/_version.py +3 -0
- plimsoll_client/_wire.py +576 -0
- plimsoll_client/aio.py +273 -0
- plimsoll_client/client.py +746 -0
- plimsoll_client/errors.py +266 -0
- plimsoll_client/py.typed +0 -0
- plimsoll_client/types.py +351 -0
- plimsoll_client-0.19.0.dist-info/METADATA +230 -0
- plimsoll_client-0.19.0.dist-info/RECORD +16 -0
- plimsoll_client-0.19.0.dist-info/WHEEL +5 -0
- plimsoll_client-0.19.0.dist-info/licenses/LICENSE +201 -0
- plimsoll_client-0.19.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""A Python client for plimsolld, the plimsoll sandbox daemon.
|
|
2
|
+
|
|
3
|
+
from plimsoll_client import Client
|
|
4
|
+
|
|
5
|
+
c = Client("http://127.0.0.1:8080", token="...")
|
|
6
|
+
r = c.run_javascript("console.log(6*7)", minimum_isolation="container")
|
|
7
|
+
print(r.stdout_text, r.isolation, r.record.sha256)
|
|
8
|
+
|
|
9
|
+
Standard library only. Every answered run's record is recomputed and checked, the
|
|
10
|
+
isolation floor is sent and the answer's tier checked against it, a session's
|
|
11
|
+
chain of records is followed call by call, and refusals come back as typed errors
|
|
12
|
+
that say whether anything ran. See README.md beside this package.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ._record import VERSION as RECORD_VERSION
|
|
16
|
+
from ._transport import MAX_RESPONSE_BYTES
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
from .aio import AsyncClient, AsyncSession
|
|
19
|
+
from .client import PROTOCOL, Client, Session
|
|
20
|
+
from .errors import (
|
|
21
|
+
AtCapacityError,
|
|
22
|
+
ChainError,
|
|
23
|
+
DataLossError,
|
|
24
|
+
DisabledError,
|
|
25
|
+
InsecureHTTPError,
|
|
26
|
+
InsufficientIsolationError,
|
|
27
|
+
InvalidBaseURLError,
|
|
28
|
+
InvalidOptionError,
|
|
29
|
+
InvalidRequestError,
|
|
30
|
+
IsolationEvidenceMismatchError,
|
|
31
|
+
MalformedResponseError,
|
|
32
|
+
NoRecordError,
|
|
33
|
+
PlimsollError,
|
|
34
|
+
ProtocolMismatchError,
|
|
35
|
+
RecordMismatchError,
|
|
36
|
+
RecordVersionError,
|
|
37
|
+
RequestTimeoutError,
|
|
38
|
+
ResponseTooLargeError,
|
|
39
|
+
ResultKindMismatchError,
|
|
40
|
+
SessionEndedError,
|
|
41
|
+
SoftwareEvidenceMismatchError,
|
|
42
|
+
SoftwareMismatchError,
|
|
43
|
+
TransportError,
|
|
44
|
+
UnsupportedError,
|
|
45
|
+
)
|
|
46
|
+
from .types import (
|
|
47
|
+
CellResult,
|
|
48
|
+
AdviceFinding,
|
|
49
|
+
Artifact,
|
|
50
|
+
Info,
|
|
51
|
+
JavaScriptResult,
|
|
52
|
+
ModuleResult,
|
|
53
|
+
ModuleRun,
|
|
54
|
+
PayloadEnvironment,
|
|
55
|
+
ProjectResult,
|
|
56
|
+
Resources,
|
|
57
|
+
RunEvidence,
|
|
58
|
+
RunRecord,
|
|
59
|
+
SessionEnded,
|
|
60
|
+
SessionSummary,
|
|
61
|
+
SoftwareRule,
|
|
62
|
+
StepResult,
|
|
63
|
+
meets,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"CellResult",
|
|
68
|
+
"__version__",
|
|
69
|
+
"PROTOCOL",
|
|
70
|
+
"RECORD_VERSION",
|
|
71
|
+
"MAX_RESPONSE_BYTES",
|
|
72
|
+
"Client",
|
|
73
|
+
"Session",
|
|
74
|
+
"AsyncClient",
|
|
75
|
+
"AsyncSession",
|
|
76
|
+
"SoftwareRule",
|
|
77
|
+
"meets",
|
|
78
|
+
"Info",
|
|
79
|
+
"PayloadEnvironment",
|
|
80
|
+
"Resources",
|
|
81
|
+
"RunEvidence",
|
|
82
|
+
"RunRecord",
|
|
83
|
+
"AdviceFinding",
|
|
84
|
+
"JavaScriptResult",
|
|
85
|
+
"ProjectResult",
|
|
86
|
+
"StepResult",
|
|
87
|
+
"Artifact",
|
|
88
|
+
"ModuleResult",
|
|
89
|
+
"ModuleRun",
|
|
90
|
+
"SessionEnded",
|
|
91
|
+
"SessionSummary",
|
|
92
|
+
"PlimsollError",
|
|
93
|
+
"InvalidBaseURLError",
|
|
94
|
+
"InvalidOptionError",
|
|
95
|
+
"InsecureHTTPError",
|
|
96
|
+
"InvalidRequestError",
|
|
97
|
+
"InsufficientIsolationError",
|
|
98
|
+
"SoftwareMismatchError",
|
|
99
|
+
"DisabledError",
|
|
100
|
+
"UnsupportedError",
|
|
101
|
+
"ProtocolMismatchError",
|
|
102
|
+
"AtCapacityError",
|
|
103
|
+
"SessionEndedError",
|
|
104
|
+
"TransportError",
|
|
105
|
+
"RequestTimeoutError",
|
|
106
|
+
"ResponseTooLargeError",
|
|
107
|
+
"MalformedResponseError",
|
|
108
|
+
"DataLossError",
|
|
109
|
+
"RecordMismatchError",
|
|
110
|
+
"NoRecordError",
|
|
111
|
+
"RecordVersionError",
|
|
112
|
+
"ChainError",
|
|
113
|
+
"IsolationEvidenceMismatchError",
|
|
114
|
+
"SoftwareEvidenceMismatchError",
|
|
115
|
+
"ResultKindMismatchError",
|
|
116
|
+
]
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Run-record digests and checks, ported from the Go package ``record``.
|
|
2
|
+
|
|
3
|
+
Every digest is lowercase hex SHA-256 over a length-prefixed encoding, never over
|
|
4
|
+
protobuf or JSON bytes (docs/run-records.md). The digests here are computed from
|
|
5
|
+
the JSON forms of the messages: the request exactly as this client sends it, the
|
|
6
|
+
response exactly as it was parsed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import struct
|
|
13
|
+
from typing import Iterable, Optional
|
|
14
|
+
|
|
15
|
+
from ._wire import INT32, INT64, OUTCOMES, UINT32, UINT64, Msg
|
|
16
|
+
from .errors import IsolationEvidenceMismatchError, NoRecordError, RecordMismatchError, RecordVersionError
|
|
17
|
+
from .types import RunRecord, SoftwareRule, meets
|
|
18
|
+
|
|
19
|
+
VERSION = 2
|
|
20
|
+
"""The record encoding version this client computes and checks (record.Version)."""
|
|
21
|
+
|
|
22
|
+
UNANSWERED_VERSION = 3
|
|
23
|
+
"""The record version of a session call that may have run but ended in an error
|
|
24
|
+
(record.UnansweredVersion): version 2's fields, then ``unanswered``."""
|
|
25
|
+
|
|
26
|
+
_UNANSWERED_CODES = frozenset((
|
|
27
|
+
"canceled", "unknown", "invalid_argument", "deadline_exceeded", "not_found", "already_exists",
|
|
28
|
+
"permission_denied", "resource_exhausted", "failed_precondition", "aborted", "out_of_range",
|
|
29
|
+
"unimplemented", "internal", "unavailable", "data_loss", "unauthenticated",
|
|
30
|
+
))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Encoder:
|
|
34
|
+
def __init__(self, domain: str) -> None:
|
|
35
|
+
self._h = hashlib.sha256()
|
|
36
|
+
self._value(domain.encode("utf-8"))
|
|
37
|
+
|
|
38
|
+
def _value(self, b: bytes) -> None:
|
|
39
|
+
self._h.update(struct.pack(">Q", len(b)))
|
|
40
|
+
self._h.update(b)
|
|
41
|
+
|
|
42
|
+
def bytes(self, name: str, v: bytes) -> None:
|
|
43
|
+
self._value(name.encode("utf-8"))
|
|
44
|
+
self._value(v)
|
|
45
|
+
|
|
46
|
+
def str(self, name: str, v: str) -> None:
|
|
47
|
+
self.bytes(name, v.encode("utf-8"))
|
|
48
|
+
|
|
49
|
+
def int(self, name: str, v: int) -> None:
|
|
50
|
+
self.str(name, "%d" % v)
|
|
51
|
+
|
|
52
|
+
def bool(self, name: str, v: bool) -> None:
|
|
53
|
+
self.str(name, "true" if v else "false")
|
|
54
|
+
|
|
55
|
+
def floats(self, name: str, vs: Iterable[float]) -> None:
|
|
56
|
+
self.bytes(name, b"".join(struct.pack(">d", v) for v in vs))
|
|
57
|
+
|
|
58
|
+
def sum(self) -> str:
|
|
59
|
+
return self._h.hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def request_digest(req: dict) -> str:
|
|
63
|
+
"""The request digest of a Run request or a session call, from its JSON form.
|
|
64
|
+
The trace ID and the session ID are left out (record.RunRequestDigest and
|
|
65
|
+
record.SessionRunRequestDigest)."""
|
|
66
|
+
m = Msg(req, "request")
|
|
67
|
+
protocol = m.get_int("protocol", UINT32)
|
|
68
|
+
e = _Encoder("plimsoll.run-request.v2" if protocol >= 2 else "plimsoll.run-request.v1")
|
|
69
|
+
e.int("protocol", protocol)
|
|
70
|
+
e.str("minimum_isolation", m.get_str("minimumIsolation"))
|
|
71
|
+
e.int("timeout_ms", m.get_int("timeoutMs", INT32))
|
|
72
|
+
if protocol >= 2:
|
|
73
|
+
rule = m.get_msg("softwareRule")
|
|
74
|
+
ids = rule.get_strs("identities") if rule else []
|
|
75
|
+
e.str("software_mode", rule.get_str("mode") if rule else "")
|
|
76
|
+
e.int("software_identities", len(ids))
|
|
77
|
+
for i in ids:
|
|
78
|
+
e.str("software_identity", i)
|
|
79
|
+
js, project, module, cell = m.get_msg("javascript"), m.get_msg("project"), m.get_msg("module"), m.get_msg("cell")
|
|
80
|
+
if js is not None:
|
|
81
|
+
e.str("kind", "javascript")
|
|
82
|
+
e.str("code", js.get_str("code"))
|
|
83
|
+
e.str("grant_profile", js.get_str("grantProfile"))
|
|
84
|
+
elif cell is not None:
|
|
85
|
+
e.str("kind", "cell")
|
|
86
|
+
e.str("language", cell.get_str("language"))
|
|
87
|
+
e.str("code", cell.get_str("code"))
|
|
88
|
+
files = cell.get_msgs("files")
|
|
89
|
+
e.int("files", len(files))
|
|
90
|
+
for f in files:
|
|
91
|
+
e.str("file_path", f.get_str("path"))
|
|
92
|
+
e.str("file_content", f.get_str("content"))
|
|
93
|
+
elif project is not None:
|
|
94
|
+
e.str("kind", "project")
|
|
95
|
+
e.str("grant_profile", project.get_str("grantProfile"))
|
|
96
|
+
files = project.get_msgs("files")
|
|
97
|
+
e.int("files", len(files))
|
|
98
|
+
for f in files:
|
|
99
|
+
e.str("file_path", f.get_str("path"))
|
|
100
|
+
e.str("file_content", f.get_str("content"))
|
|
101
|
+
steps = project.get_strs("steps")
|
|
102
|
+
e.int("steps", len(steps))
|
|
103
|
+
for s in steps:
|
|
104
|
+
e.str("step_command", s)
|
|
105
|
+
artifacts = project.get_strs("artifacts")
|
|
106
|
+
e.int("artifacts", len(artifacts))
|
|
107
|
+
for a in artifacts:
|
|
108
|
+
e.str("artifact_path", a)
|
|
109
|
+
elif module is not None:
|
|
110
|
+
e.str("kind", "module")
|
|
111
|
+
e.str("model", module.get_str("model"))
|
|
112
|
+
e.floats("end_time", [module.get_float("endTime")])
|
|
113
|
+
e.floats("step", [module.get_float("step")])
|
|
114
|
+
rows = module.get_msgs("rows")
|
|
115
|
+
e.int("rows", len(rows))
|
|
116
|
+
for r in rows:
|
|
117
|
+
e.floats("row_values", r.get_floats("values"))
|
|
118
|
+
else:
|
|
119
|
+
e.str("kind", "")
|
|
120
|
+
return e.sum()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def result_digest(resp: Msg) -> str:
|
|
124
|
+
"""The result digest of a RunResponse as received (record.ResultDigest):
|
|
125
|
+
durations, advice and the evidence fields are left out."""
|
|
126
|
+
e = _Encoder("plimsoll.run-result.v1")
|
|
127
|
+
js, project, module, cell = resp.get_msg("javascript"), resp.get_msg("project"), resp.get_msg("module"), resp.get_msg("cell")
|
|
128
|
+
if cell is not None:
|
|
129
|
+
e.str("kind", "cell")
|
|
130
|
+
e.int("exit_code", cell.get_int("exitCode", INT32))
|
|
131
|
+
e.bool("timed_out", cell.get_bool("timedOut"))
|
|
132
|
+
e.bytes("stdout", cell.get_bytes("stdout"))
|
|
133
|
+
e.bytes("stderr", cell.get_bytes("stderr"))
|
|
134
|
+
e.bool("stdout_truncated", cell.get_bool("stdoutTruncated"))
|
|
135
|
+
e.bool("stderr_truncated", cell.get_bool("stderrTruncated"))
|
|
136
|
+
e.bool("interpreter_started", cell.get_bool("interpreterStarted"))
|
|
137
|
+
e.bool("interpreter_ended", cell.get_bool("interpreterEnded"))
|
|
138
|
+
elif js is not None:
|
|
139
|
+
e.str("kind", "javascript")
|
|
140
|
+
e.int("exit_code", js.get_int("exitCode", INT32))
|
|
141
|
+
e.bool("timed_out", js.get_bool("timedOut"))
|
|
142
|
+
e.bytes("stdout", js.get_bytes("stdout"))
|
|
143
|
+
e.bytes("stderr", js.get_bytes("stderr"))
|
|
144
|
+
e.bool("stdout_truncated", js.get_bool("stdoutTruncated"))
|
|
145
|
+
e.bool("stderr_truncated", js.get_bool("stderrTruncated"))
|
|
146
|
+
elif project is not None:
|
|
147
|
+
e.str("kind", "project")
|
|
148
|
+
e.int("outcome", project.get_enum("outcome", OUTCOMES))
|
|
149
|
+
e.str("outcome_detail", project.get_str("outcomeDetail"))
|
|
150
|
+
e.bool("artifacts_truncated", project.get_bool("artifactsTruncated"))
|
|
151
|
+
steps = project.get_msgs("steps")
|
|
152
|
+
e.int("steps", len(steps))
|
|
153
|
+
for s in steps:
|
|
154
|
+
e.str("step_command", s.get_str("command"))
|
|
155
|
+
e.int("step_exit_code", s.get_int("exitCode", INT32))
|
|
156
|
+
e.bool("step_timed_out", s.get_bool("timedOut"))
|
|
157
|
+
e.bytes("step_stdout", s.get_bytes("stdout"))
|
|
158
|
+
e.bytes("step_stderr", s.get_bytes("stderr"))
|
|
159
|
+
e.bool("step_stdout_truncated", s.get_bool("stdoutTruncated"))
|
|
160
|
+
e.bool("step_stderr_truncated", s.get_bool("stderrTruncated"))
|
|
161
|
+
artifacts = project.get_msgs("artifacts")
|
|
162
|
+
e.int("artifacts", len(artifacts))
|
|
163
|
+
for a in artifacts:
|
|
164
|
+
e.str("artifact_path", a.get_str("path"))
|
|
165
|
+
e.bytes("artifact_content", a.get_bytes("content"))
|
|
166
|
+
elif module is not None:
|
|
167
|
+
e.str("kind", "module")
|
|
168
|
+
e.int("outcome", module.get_enum("outcome", OUTCOMES))
|
|
169
|
+
e.str("outcome_detail", module.get_str("outcomeDetail"))
|
|
170
|
+
e.int("width", module.get_int("width", INT32))
|
|
171
|
+
e.bytes("stdout", module.get_bytes("stdout"))
|
|
172
|
+
e.bytes("stderr", module.get_bytes("stderr"))
|
|
173
|
+
runs = module.get_msgs("runs")
|
|
174
|
+
e.int("runs", len(runs))
|
|
175
|
+
for run in runs:
|
|
176
|
+
e.int("run_status", run.get_int("status", INT32))
|
|
177
|
+
e.floats("run_outputs", run.get_floats("outputs"))
|
|
178
|
+
else:
|
|
179
|
+
e.str("kind", "")
|
|
180
|
+
return e.sum()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def record_digest(r: RunRecord) -> str:
|
|
184
|
+
"""A record's own digest over every field but ``sha256`` (record.Digest). The
|
|
185
|
+
version is the domain's suffix."""
|
|
186
|
+
e = _Encoder("plimsoll.run-record.v%d" % r.version)
|
|
187
|
+
e.str("request_sha256", r.request_sha256)
|
|
188
|
+
e.str("result_sha256", r.result_sha256)
|
|
189
|
+
if r.version >= 3:
|
|
190
|
+
e.str("unanswered", r.unanswered)
|
|
191
|
+
e.str("provider", r.provider)
|
|
192
|
+
e.str("isolation", r.isolation)
|
|
193
|
+
e.str("environment", r.environment)
|
|
194
|
+
e.str("policy", r.policy)
|
|
195
|
+
if r.version >= 2:
|
|
196
|
+
e.str("software_identity", r.software_identity)
|
|
197
|
+
e.str("software_rule_id", r.software_rule_id)
|
|
198
|
+
e.int("started_unix_ms", r.started_unix_ms)
|
|
199
|
+
e.int("ended_unix_ms", r.ended_unix_ms)
|
|
200
|
+
e.str("session", r.session)
|
|
201
|
+
e.int("sequence", r.sequence)
|
|
202
|
+
e.str("previous_sha256", r.previous_sha256)
|
|
203
|
+
return e.sum()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def session_fingerprint(session_id: str) -> str:
|
|
207
|
+
"""The SHA-256 of a session ID, which a session call's record carries instead
|
|
208
|
+
of the ID (record.SessionFingerprint)."""
|
|
209
|
+
return hashlib.sha256(session_id.encode("utf-8")).hexdigest()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def record_from_wire(m: Msg) -> RunRecord:
|
|
213
|
+
return RunRecord(
|
|
214
|
+
version=m.get_int("version", UINT32),
|
|
215
|
+
request_sha256=m.get_str("requestSha256"),
|
|
216
|
+
result_sha256=m.get_str("resultSha256"),
|
|
217
|
+
provider=m.get_str("provider"),
|
|
218
|
+
isolation=m.get_str("isolation"),
|
|
219
|
+
environment=m.get_str("environment"),
|
|
220
|
+
policy=m.get_str("policy"),
|
|
221
|
+
software_identity=m.get_str("softwareIdentity"),
|
|
222
|
+
software_rule_id=m.get_str("softwareRuleId"),
|
|
223
|
+
started_unix_ms=m.get_int("startedUnixMs", INT64),
|
|
224
|
+
ended_unix_ms=m.get_int("endedUnixMs", INT64),
|
|
225
|
+
session=m.get_str("session"),
|
|
226
|
+
sequence=m.get_int("sequence", UINT64),
|
|
227
|
+
previous_sha256=m.get_str("previousSha256"),
|
|
228
|
+
sha256=m.get_str("recordSha256"),
|
|
229
|
+
unanswered=m.get_str("unanswered"),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def check(req_digest: str, protocol: int, rule: Optional[SoftwareRule], resp: Msg) -> RunRecord:
|
|
234
|
+
"""Everything a record states that the caller can recompute (record.check):
|
|
235
|
+
the version, both digests, the evidence the response repeats, the software
|
|
236
|
+
rule and the record's own digest. The rule was validated before sending."""
|
|
237
|
+
rec_msg = resp.get_msg("record")
|
|
238
|
+
if rec_msg is None:
|
|
239
|
+
raise NoRecordError("plimsoll: the response carries no run record")
|
|
240
|
+
r = record_from_wire(rec_msg)
|
|
241
|
+
result = result_digest(resp)
|
|
242
|
+
if r.version not in (1, VERSION):
|
|
243
|
+
raise RecordVersionError(f"plimsoll: unknown run record version {r.version} (this client knows 1 and {VERSION})")
|
|
244
|
+
if protocol >= 2 and r.version != VERSION:
|
|
245
|
+
raise RecordVersionError(f"plimsoll: protocol {protocol} requires record version {VERSION}, the record is version {r.version}")
|
|
246
|
+
if r.version == 1 and (r.software_identity or r.software_rule_id):
|
|
247
|
+
raise RecordMismatchError("plimsoll: a version 1 record cannot carry software admission fields")
|
|
248
|
+
if r.unanswered:
|
|
249
|
+
raise RecordMismatchError(f"plimsoll: an answered call's record states an unanswered code, which version {r.version} does not cover")
|
|
250
|
+
if r.request_sha256 != req_digest:
|
|
251
|
+
raise RecordMismatchError(f"plimsoll: request digest {r.request_sha256}, the request sent digests to {req_digest}")
|
|
252
|
+
if r.result_sha256 != result:
|
|
253
|
+
raise RecordMismatchError(f"plimsoll: result digest {r.result_sha256}, the result received digests to {result}")
|
|
254
|
+
if r.provider != resp.get_str("sandbox") or r.isolation != resp.get_str("isolation"):
|
|
255
|
+
raise RecordMismatchError(
|
|
256
|
+
f"plimsoll: the record names {r.provider} at {r.isolation!r}, the response {resp.get_str('sandbox')} at {resp.get_str('isolation')!r}"
|
|
257
|
+
)
|
|
258
|
+
if r.version >= 2 and r.environment != resp.get_str("environment"):
|
|
259
|
+
raise RecordMismatchError("plimsoll: the record's environment differs from the response's")
|
|
260
|
+
if r.version >= 2:
|
|
261
|
+
rule_id = rule.rule_id() if rule is not None else ""
|
|
262
|
+
allowed = rule.allows(r.software_identity) if rule is not None else True
|
|
263
|
+
if r.software_identity != resp.get_str("softwareIdentity") or r.software_rule_id != rule_id or not allowed:
|
|
264
|
+
raise RecordMismatchError("plimsoll: the selected software or admission rule differs from the request and response")
|
|
265
|
+
own = record_digest(r)
|
|
266
|
+
if r.sha256 != own:
|
|
267
|
+
raise RecordMismatchError(f"plimsoll: record digest {r.sha256}, its fields digest to {own}")
|
|
268
|
+
return r
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def check_unanswered(req_digest: str, floor: str, rule: Optional[SoftwareRule], m: Msg) -> RunRecord:
|
|
272
|
+
"""The record an unanswered session call's error carries (record.CheckUnanswered,
|
|
273
|
+
without the chain, which the Session checks): version 3, no result digest, a
|
|
274
|
+
Connect code, the digest of the request sent, its software rule and that its
|
|
275
|
+
software is in it, the record's own digest, and its tier against the call's floor."""
|
|
276
|
+
r = record_from_wire(m)
|
|
277
|
+
if r.version != UNANSWERED_VERSION:
|
|
278
|
+
raise RecordVersionError(f"plimsoll: an unanswered call's record is version {r.version}, not {UNANSWERED_VERSION}")
|
|
279
|
+
if r.result_sha256 or r.unanswered not in _UNANSWERED_CODES:
|
|
280
|
+
raise RecordMismatchError("plimsoll: an unanswered call's record states a result or no error code")
|
|
281
|
+
if r.request_sha256 != req_digest:
|
|
282
|
+
raise RecordMismatchError(f"plimsoll: request digest {r.request_sha256}, the request sent digests to {req_digest}")
|
|
283
|
+
if r.software_rule_id != (rule.rule_id() if rule is not None else "") or (rule is not None and not rule.allows(r.software_identity)):
|
|
284
|
+
raise RecordMismatchError("plimsoll: the record's software rule is not the request's, or its software is not in it")
|
|
285
|
+
own = record_digest(r)
|
|
286
|
+
if r.sha256 != own:
|
|
287
|
+
raise RecordMismatchError(f"plimsoll: record digest {r.sha256}, its fields digest to {own}")
|
|
288
|
+
if floor and not meets(r.isolation, floor):
|
|
289
|
+
raise IsolationEvidenceMismatchError(
|
|
290
|
+
f"plimsoll: the unanswered call's record states {r.isolation or 'unknown'}, below the call's minimum {floor}; execution may have occurred"
|
|
291
|
+
)
|
|
292
|
+
return r
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def check_single(req_digest: str, protocol: int, rule: Optional[SoftwareRule], resp: Msg) -> RunRecord:
|
|
296
|
+
"""check, for a single run: a single run's record carries no session fields (record.Check)."""
|
|
297
|
+
r = check(req_digest, protocol, rule, resp)
|
|
298
|
+
if r.session or r.sequence or r.previous_sha256:
|
|
299
|
+
raise RecordMismatchError("plimsoll: a single run's record carries session fields")
|
|
300
|
+
return r
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""One unary Connect call with the JSON codec, over HTTP/1.1.
|
|
2
|
+
|
|
3
|
+
It uses http.client (the layer urllib.request is built on) directly, for three
|
|
4
|
+
reasons: a redirect is never followed (a Connect call has none, and following one
|
|
5
|
+
would carry the bearer token and the code to another address); proxy settings in
|
|
6
|
+
the environment are not consulted; and the request timeout bounds the whole
|
|
7
|
+
exchange, not each socket read, so a daemon that trickles its answer a byte at a
|
|
8
|
+
time cannot hold the caller past it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import http.client
|
|
14
|
+
import re
|
|
15
|
+
import socket
|
|
16
|
+
import ssl
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
20
|
+
from urllib.parse import urlsplit
|
|
21
|
+
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
from ._wire import Msg, dumps, error_from_wire, parse_response
|
|
24
|
+
from .errors import MalformedResponseError, RequestTimeoutError, ResponseTooLargeError, TransportError
|
|
25
|
+
|
|
26
|
+
SERVICE = "plimsoll.v1.SandboxService"
|
|
27
|
+
|
|
28
|
+
MAX_RESPONSE_BYTES = 32 << 20
|
|
29
|
+
"""Above the largest legitimate project answer (bounded step output plus the
|
|
30
|
+
aggregate artifact cap), so a misbehaving daemon cannot make the client buffer
|
|
31
|
+
without bound: the Go client's maxResponseBytes."""
|
|
32
|
+
|
|
33
|
+
_CHUNK = 1 << 16
|
|
34
|
+
|
|
35
|
+
_DIGITS = re.compile(r"[0-9]+")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Transport:
|
|
39
|
+
def __init__(self, base_url: str, token: Optional[str], timeout: float, ssl_context: Optional[ssl.SSLContext]) -> None:
|
|
40
|
+
u = urlsplit(base_url)
|
|
41
|
+
self._https = u.scheme == "https"
|
|
42
|
+
self._host = u.hostname or ""
|
|
43
|
+
self._port = u.port
|
|
44
|
+
self._prefix = u.path.rstrip("/")
|
|
45
|
+
self._token = token
|
|
46
|
+
self._timeout = timeout
|
|
47
|
+
self._ssl = ssl_context if ssl_context is not None else (ssl.create_default_context() if self._https else None)
|
|
48
|
+
|
|
49
|
+
def _connection(self) -> http.client.HTTPConnection:
|
|
50
|
+
if self._https:
|
|
51
|
+
return http.client.HTTPSConnection(self._host, self._port, timeout=self._timeout, context=self._ssl)
|
|
52
|
+
return http.client.HTTPConnection(self._host, self._port, timeout=self._timeout)
|
|
53
|
+
|
|
54
|
+
def call(self, method: str, body: Dict[str, Any], path: str) -> Msg:
|
|
55
|
+
"""POSTs body to the procedure and returns the answer as a message. A
|
|
56
|
+
Connect error answer raises the restored error; anything that is not a
|
|
57
|
+
Connect answer raises TransportError."""
|
|
58
|
+
data = dumps(body)
|
|
59
|
+
headers = {
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
"Connect-Protocol-Version": "1",
|
|
62
|
+
"Accept-Encoding": "identity",
|
|
63
|
+
"User-Agent": f"plimsoll-client-python/{__version__}",
|
|
64
|
+
}
|
|
65
|
+
if self._token:
|
|
66
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
67
|
+
deadline = time.monotonic() + self._timeout
|
|
68
|
+
conn = self._connection()
|
|
69
|
+
fired = threading.Event()
|
|
70
|
+
timer: Optional[threading.Timer] = None
|
|
71
|
+
try:
|
|
72
|
+
try:
|
|
73
|
+
conn.connect()
|
|
74
|
+
# The socket timeout bounds each read; the timer bounds them all,
|
|
75
|
+
# by shutting the socket down when the deadline passes.
|
|
76
|
+
timer = threading.Timer(max(deadline - time.monotonic(), 0.0), _abort, (conn, fired))
|
|
77
|
+
timer.daemon = True
|
|
78
|
+
timer.start()
|
|
79
|
+
try:
|
|
80
|
+
conn.request("POST", f"{self._prefix}/{SERVICE}/{method}", body=data, headers=headers)
|
|
81
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
82
|
+
# The daemon can answer before the whole body is sent (a refusal
|
|
83
|
+
# needs no payload) and close its end; its answer, which may say
|
|
84
|
+
# nothing ran, is read below instead of being lost to the write.
|
|
85
|
+
if fired.is_set():
|
|
86
|
+
raise
|
|
87
|
+
_settimeout(conn, deadline)
|
|
88
|
+
resp = conn.getresponse()
|
|
89
|
+
raw, complete = _read_capped(conn, resp, deadline)
|
|
90
|
+
# The timer shuts the socket down, which ends a read early: an answer
|
|
91
|
+
# cut short is a timeout, but one read to its stated end is the answer.
|
|
92
|
+
if fired.is_set() and not complete:
|
|
93
|
+
raise socket.timeout("deadline passed")
|
|
94
|
+
except (socket.timeout, TimeoutError):
|
|
95
|
+
raise RequestTimeoutError(f"plimsoll: {method} did not finish within {self._timeout} s") from None
|
|
96
|
+
except (OSError, http.client.HTTPException) as e:
|
|
97
|
+
if fired.is_set():
|
|
98
|
+
raise RequestTimeoutError(f"plimsoll: {method} did not finish within {self._timeout} s") from None
|
|
99
|
+
raise TransportError(f"plimsoll: {method}: {e}") from e
|
|
100
|
+
finally:
|
|
101
|
+
if timer is not None:
|
|
102
|
+
timer.cancel()
|
|
103
|
+
conn.close()
|
|
104
|
+
if resp.status != 200:
|
|
105
|
+
raise error_from_wire(resp.status, raw)
|
|
106
|
+
ctype = (resp.getheader("Content-Type") or "").split(";", 1)[0].strip().lower()
|
|
107
|
+
if ctype != "application/json":
|
|
108
|
+
raise MalformedResponseError(f"plimsoll: {method} answered with content type {ctype or 'none'!r}, not application/json")
|
|
109
|
+
return parse_response(raw, path)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _abort(conn: http.client.HTTPConnection, fired: threading.Event) -> None:
|
|
113
|
+
fired.set()
|
|
114
|
+
sock = conn.sock
|
|
115
|
+
if sock is not None:
|
|
116
|
+
try:
|
|
117
|
+
sock.shutdown(socket.SHUT_RDWR)
|
|
118
|
+
except OSError:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _settimeout(conn: http.client.HTTPConnection, deadline: float) -> None:
|
|
123
|
+
remaining = deadline - time.monotonic()
|
|
124
|
+
if remaining <= 0:
|
|
125
|
+
raise socket.timeout("deadline passed")
|
|
126
|
+
if conn.sock is not None:
|
|
127
|
+
conn.sock.settimeout(remaining)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _read_capped(conn: http.client.HTTPConnection, resp: http.client.HTTPResponse, deadline: float) -> Tuple[bytes, bool]:
|
|
131
|
+
"""The answer's body, and whether it is known complete: read to the length its
|
|
132
|
+
Content-Length states."""
|
|
133
|
+
header = (resp.getheader("Content-Length") or "").strip()
|
|
134
|
+
# ASCII digits only: str.isdigit() also accepts "²", which int() refuses.
|
|
135
|
+
length = int(header) if _DIGITS.fullmatch(header) else None
|
|
136
|
+
if length is not None and length > MAX_RESPONSE_BYTES:
|
|
137
|
+
raise ResponseTooLargeError(f"plimsoll: the daemon's answer is {length} bytes, over the {MAX_RESPONSE_BYTES} byte limit")
|
|
138
|
+
chunks: List[bytes] = []
|
|
139
|
+
n = 0
|
|
140
|
+
while True:
|
|
141
|
+
_settimeout(conn, deadline)
|
|
142
|
+
chunk = resp.read1(_CHUNK) # one read at most, so the deadline is checked between reads
|
|
143
|
+
if not chunk:
|
|
144
|
+
break
|
|
145
|
+
n += len(chunk)
|
|
146
|
+
if n > MAX_RESPONSE_BYTES:
|
|
147
|
+
raise ResponseTooLargeError(f"plimsoll: the daemon's answer exceeds {MAX_RESPONSE_BYTES} bytes")
|
|
148
|
+
chunks.append(chunk)
|
|
149
|
+
return b"".join(chunks), length is not None and n == length
|