sayfirst-cli 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.
- sayfirst_cli/__init__.py +2 -0
- sayfirst_cli/approvals.py +127 -0
- sayfirst_cli/ask.py +167 -0
- sayfirst_cli/evidence.py +668 -0
- sayfirst_cli/exit_codes.py +88 -0
- sayfirst_cli/explain.py +35 -0
- sayfirst_cli/instrument/__init__.py +14 -0
- sayfirst_cli/instrument/commands.py +191 -0
- sayfirst_cli/instrument/engine.py +396 -0
- sayfirst_cli/instrument/harness.py +1162 -0
- sayfirst_cli/instrument/launch.py +417 -0
- sayfirst_cli/instrument/manifest.py +409 -0
- sayfirst_cli/instrument/verify.py +553 -0
- sayfirst_cli/main.py +78 -0
- sayfirst_cli/packs/__init__.py +10 -0
- sayfirst_cli/packs/database/NOTE.md +4 -0
- sayfirst_cli/packs/database/interpose.py +85 -0
- sayfirst_cli/packs/database/pack.toml +13 -0
- sayfirst_cli/packs/http-client/NOTE.md +4 -0
- sayfirst_cli/packs/http-client/interpose.py +72 -0
- sayfirst_cli/packs/http-client/pack.toml +13 -0
- sayfirst_cli/packs/subprocess/NOTE.md +4 -0
- sayfirst_cli/packs/subprocess/interpose.py +90 -0
- sayfirst_cli/packs/subprocess/pack.toml +13 -0
- sayfirst_cli/packs_cmd.py +115 -0
- sayfirst_cli/pages.py +87 -0
- sayfirst_cli/reads.py +254 -0
- sayfirst_cli/render.py +138 -0
- sayfirst_cli/trace.py +88 -0
- sayfirst_cli-0.2.0.dist-info/METADATA +297 -0
- sayfirst_cli-0.2.0.dist-info/RECORD +35 -0
- sayfirst_cli-0.2.0.dist-info/WHEEL +4 -0
- sayfirst_cli-0.2.0.dist-info/entry_points.txt +2 -0
- sayfirst_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
- sayfirst_cli-0.2.0.dist-info/licenses/NOTICE +13 -0
sayfirst_cli/evidence.py
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Read evidence and render the contract's local checks beside the served verdicts."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Final, TextIO
|
|
13
|
+
|
|
14
|
+
from sayfirst_contract.client import Answered, CouldNotAsk, Refused, Result
|
|
15
|
+
from sayfirst_contract.evidence import (
|
|
16
|
+
ChainCondition,
|
|
17
|
+
ChainVerdict,
|
|
18
|
+
ExportVerdict,
|
|
19
|
+
Rederivation,
|
|
20
|
+
verify_chain,
|
|
21
|
+
verify_export,
|
|
22
|
+
)
|
|
23
|
+
from sayfirst_contract.generation import CONTRACT_GENERATION
|
|
24
|
+
from sayfirst_contract.problems import Problem, ProblemCode, problem_retryable
|
|
25
|
+
from sayfirst_contract.transport.socket_client import PER_USER, SYSTEM
|
|
26
|
+
|
|
27
|
+
from . import exit_codes, pages, reads, render
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def exit_for(verdict: ChainVerdict | ExportVerdict) -> int:
|
|
31
|
+
"""One exit policy for the contract's chain and export verdicts.
|
|
32
|
+
|
|
33
|
+
**A member this client does not know is read as « could not check »
|
|
34
|
+
wherever one of the two tables below decides the answer, and never as a
|
|
35
|
+
traceback.** A generation that adds a member to either vocabulary is one
|
|
36
|
+
whose conclusion this client cannot render — which is a check it cannot
|
|
37
|
+
perform, exactly what 7 is for. On the export path the contract's own
|
|
38
|
+
`overall` governs, as it does for every chain condition that is not a
|
|
39
|
+
finding: an unknown chain condition under a confirmed rederivation is not
|
|
40
|
+
made to outrank the conclusion the contract gave, because this client
|
|
41
|
+
renders verdicts and does not re-judge them. Read through `.get` rather than a
|
|
42
|
+
subscript, because the alternative is a `KeyError` out of a read, and a
|
|
43
|
+
traceback ends the process with exit 1, this client's published code for
|
|
44
|
+
« the control plane answered deny ». Article 13 asks that an unknown value
|
|
45
|
+
be read as unknown; this is that rule applied to the client's own reading of
|
|
46
|
+
a verdict, and it is the direction an unknown has to fail in (article 2).
|
|
47
|
+
|
|
48
|
+
The tables stay exhaustive over the members that exist, so a member this
|
|
49
|
+
generation DOES define can never quietly take the fallback; nothing here is
|
|
50
|
+
a list of conditions to keep in step with the contract.
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(verdict, ChainVerdict):
|
|
53
|
+
return {
|
|
54
|
+
ChainCondition.intact: 0,
|
|
55
|
+
ChainCondition.broken_at: exit_codes.EXIT_CHECK_FAILED,
|
|
56
|
+
ChainCondition.gap_at: exit_codes.EXIT_CHECK_FAILED,
|
|
57
|
+
ChainCondition.unverifiable: exit_codes.EXIT_COULD_NOT_CHECK,
|
|
58
|
+
}.get(verdict.condition, exit_codes.EXIT_COULD_NOT_CHECK)
|
|
59
|
+
# Rederivation names the contract's conclusion: confirmed is success,
|
|
60
|
+
# differs is a finding, and unverifiable cannot conclude. The latter and
|
|
61
|
+
# an unknown manifest recipe take precedence over export failures.
|
|
62
|
+
overall_exit = {
|
|
63
|
+
Rederivation.confirmed: 0,
|
|
64
|
+
Rederivation.differs: exit_codes.EXIT_CHECK_FAILED,
|
|
65
|
+
Rederivation.unverifiable: exit_codes.EXIT_COULD_NOT_CHECK,
|
|
66
|
+
}.get(verdict.overall, exit_codes.EXIT_COULD_NOT_CHECK)
|
|
67
|
+
if verdict.manifest_hash_recomputes is None or overall_exit == exit_codes.EXIT_COULD_NOT_CHECK:
|
|
68
|
+
return exit_codes.EXIT_COULD_NOT_CHECK
|
|
69
|
+
if verdict.manifest_hash_recomputes is False or (
|
|
70
|
+
verdict.chain is not None
|
|
71
|
+
and verdict.chain.condition in (ChainCondition.broken_at, ChainCondition.gap_at)
|
|
72
|
+
):
|
|
73
|
+
return exit_codes.EXIT_CHECK_FAILED
|
|
74
|
+
return overall_exit
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _page_size(value: str) -> int:
|
|
78
|
+
number = reads.positive(value)
|
|
79
|
+
if number > 100:
|
|
80
|
+
raise argparse.ArgumentTypeError("must be at most 100")
|
|
81
|
+
return number
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _history(argv: Sequence[str], *, out: TextIO, err: TextIO) -> int:
|
|
85
|
+
parser = argparse.ArgumentParser(prog="sayfirst evidence history")
|
|
86
|
+
reads.add_connection_arguments(parser)
|
|
87
|
+
parser.add_argument("--from", dest="from_sequence", type=reads.positive, required=True)
|
|
88
|
+
parser.add_argument("--page-size", type=_page_size, default=100)
|
|
89
|
+
parser.add_argument("--all", action="store_true", help="follow every continuation")
|
|
90
|
+
return _online(parser.parse_args(argv), out, err, history=True)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _audit(argv: Sequence[str], *, out: TextIO, err: TextIO) -> int:
|
|
94
|
+
parser = argparse.ArgumentParser(prog="sayfirst evidence audit")
|
|
95
|
+
source = parser.add_mutually_exclusive_group(required=True)
|
|
96
|
+
source.add_argument("--file", type=Path, help="check an export without opening a socket")
|
|
97
|
+
source.add_argument("--socket", help="the path of the daemon's socket")
|
|
98
|
+
parser.add_argument("--scope", help="the scope the question is asked in")
|
|
99
|
+
parser.add_argument("--mode", choices=(PER_USER, SYSTEM), default=PER_USER)
|
|
100
|
+
parser.add_argument("--daemon-user", default=None)
|
|
101
|
+
parser.add_argument("--from", dest="from_sequence", type=reads.positive)
|
|
102
|
+
parser.add_argument("--to", dest="to_sequence", type=reads.positive)
|
|
103
|
+
parser.add_argument("--json", action="store_true", help="write the envelope instead of prose")
|
|
104
|
+
arguments = parser.parse_args(argv)
|
|
105
|
+
if arguments.file is not None:
|
|
106
|
+
if arguments.scope is not None:
|
|
107
|
+
parser.error("--file is not allowed with --scope")
|
|
108
|
+
if arguments.from_sequence is not None or arguments.to_sequence is not None:
|
|
109
|
+
parser.error("--from and --to require --socket")
|
|
110
|
+
return _offline(arguments, out, err)
|
|
111
|
+
if arguments.scope is None or arguments.from_sequence is None:
|
|
112
|
+
parser.error("online audit requires --scope and --from")
|
|
113
|
+
if arguments.to_sequence is not None and arguments.to_sequence < arguments.from_sequence:
|
|
114
|
+
parser.error("--to must be at least --from")
|
|
115
|
+
arguments.page_size = 100
|
|
116
|
+
return _online(arguments, out, err, history=False)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _export(argv: Sequence[str], *, out: TextIO, err: TextIO) -> int:
|
|
120
|
+
parser = argparse.ArgumentParser(prog="sayfirst evidence export")
|
|
121
|
+
reads.add_connection_arguments(parser)
|
|
122
|
+
parser.add_argument("--from", dest="from_sequence", type=reads.positive, required=True)
|
|
123
|
+
parser.add_argument("--to", dest="to_sequence", type=reads.positive)
|
|
124
|
+
parser.add_argument("--out", required=True, help="save the served bundle to a new file")
|
|
125
|
+
arguments = parser.parse_args(argv)
|
|
126
|
+
if arguments.to_sequence is not None and arguments.to_sequence < arguments.from_sequence:
|
|
127
|
+
parser.error("--to must be at least --from")
|
|
128
|
+
path = Path(arguments.out)
|
|
129
|
+
# Reject every existing directory entry, including a dangling symlink,
|
|
130
|
+
# before verifying or reading from the daemon.
|
|
131
|
+
try:
|
|
132
|
+
taken = path.exists() or path.is_symlink()
|
|
133
|
+
except OSError as failure:
|
|
134
|
+
# A path this process cannot even look at (too long, in a directory
|
|
135
|
+
# it may not search) is unusable, and nothing has been asked yet.
|
|
136
|
+
err.write(f"cannot use: {arguments.out}: {failure}\n")
|
|
137
|
+
return exit_codes.EXIT_MISUSE
|
|
138
|
+
if taken:
|
|
139
|
+
err.write(f"refusing to overwrite: {arguments.out}\n")
|
|
140
|
+
return exit_codes.EXIT_MISUSE
|
|
141
|
+
connection = reads.open_connection(arguments, err)
|
|
142
|
+
if isinstance(connection, int):
|
|
143
|
+
return connection
|
|
144
|
+
try:
|
|
145
|
+
# The reply is NOT bounded before it is saved: a bundle nested past the
|
|
146
|
+
# bound is still the daemon's answer, and losing it to a rendering rule
|
|
147
|
+
# would be this client deciding what may be kept. It is saved, then the
|
|
148
|
+
# bound applies to the check below.
|
|
149
|
+
result = reads.read(
|
|
150
|
+
lambda: connection.export_evidence(
|
|
151
|
+
arguments.scope, arguments.from_sequence, arguments.to_sequence
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
if isinstance(result, Answered):
|
|
155
|
+
bundle = result.value
|
|
156
|
+
contents = json.dumps(bundle, sort_keys=True, indent=2) + "\n"
|
|
157
|
+
try:
|
|
158
|
+
# Exclusive creation also refuses a path created while the
|
|
159
|
+
# read was in flight; the early check alone cannot do that.
|
|
160
|
+
with path.open("x", encoding="utf-8") as saved:
|
|
161
|
+
saved.write(contents)
|
|
162
|
+
except OSError as failure:
|
|
163
|
+
err.write(f"could not save: {arguments.out}: {failure}\n")
|
|
164
|
+
return exit_codes.EXIT_MISUSE
|
|
165
|
+
try:
|
|
166
|
+
# The bundle is saved either way. A verifier that cannot read
|
|
167
|
+
# it is « could not check » — exit_codes.py keeps that apart
|
|
168
|
+
# from « could not ask » — and the saved path is named, so a
|
|
169
|
+
# file this command wrote is never left unreported.
|
|
170
|
+
if reads.too_deep(bundle):
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"nested deeper than {reads.DOCUMENT_DEPTH_LIMIT} levels; "
|
|
173
|
+
"not a bundle this client reads"
|
|
174
|
+
)
|
|
175
|
+
local = verify_export(bundle)
|
|
176
|
+
served = bundle["verification"]
|
|
177
|
+
count = len(bundle["entries"])
|
|
178
|
+
except reads.INPUT_ERRORS as failure:
|
|
179
|
+
return _could_not_check(
|
|
180
|
+
f"{arguments.out} (saved, not checked)",
|
|
181
|
+
failure,
|
|
182
|
+
arguments.json,
|
|
183
|
+
err,
|
|
184
|
+
verification=render.verification_document(
|
|
185
|
+
connection.server_credential.uid,
|
|
186
|
+
connection.expected_uid,
|
|
187
|
+
connection.verified,
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
document = {
|
|
191
|
+
"saved": arguments.out,
|
|
192
|
+
"served": served,
|
|
193
|
+
"local_check": local.to_document(),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
def write_saved(document: Mapping[str, object], stream: TextIO) -> None:
|
|
197
|
+
_write_export_check(bundle, local, stream)
|
|
198
|
+
stream.write(f"saved: {document['saved']} ({count} entries)\n")
|
|
199
|
+
|
|
200
|
+
reads.finish(
|
|
201
|
+
Answered(document, CONTRACT_GENERATION),
|
|
202
|
+
arguments,
|
|
203
|
+
connection,
|
|
204
|
+
out,
|
|
205
|
+
err,
|
|
206
|
+
write_saved,
|
|
207
|
+
)
|
|
208
|
+
return exit_for(local)
|
|
209
|
+
return reads.finish(result, arguments, connection, out, err, render.write_record)
|
|
210
|
+
finally:
|
|
211
|
+
connection.close()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _exports(argv: Sequence[str], *, out: TextIO, err: TextIO) -> int:
|
|
215
|
+
"""List every `*.json` file of a directory as what it is, and check the bundles.
|
|
216
|
+
|
|
217
|
+
A listing states what each entry is, so a file that does not parse is
|
|
218
|
+
listed as « not a bundle » and no check failed, while the same bytes named
|
|
219
|
+
to `audit --file` answer « could not check »: there the check is the whole
|
|
220
|
+
question, and one that could not run is not a bundle judged sound.
|
|
221
|
+
"""
|
|
222
|
+
parser = argparse.ArgumentParser(prog="sayfirst evidence exports")
|
|
223
|
+
parser.add_argument("directory", nargs="?", default=".")
|
|
224
|
+
parser.add_argument("--json", action="store_true", help="write the envelope instead of prose")
|
|
225
|
+
arguments = parser.parse_args(argv)
|
|
226
|
+
bundles: list[dict[str, object]] = []
|
|
227
|
+
codes: set[int] = set()
|
|
228
|
+
try:
|
|
229
|
+
paths = sorted(
|
|
230
|
+
(path for path in Path(arguments.directory).iterdir() if path.name.endswith(".json")),
|
|
231
|
+
key=lambda path: path.name,
|
|
232
|
+
)
|
|
233
|
+
except OSError as failure:
|
|
234
|
+
return _could_not_check(arguments.directory, failure, arguments.json, err)
|
|
235
|
+
for path in paths:
|
|
236
|
+
try:
|
|
237
|
+
regular = path.is_file()
|
|
238
|
+
except OSError as failure:
|
|
239
|
+
# The listing was readable but the entry is not even stat-able
|
|
240
|
+
# (a directory without search permission): not a bundle is not
|
|
241
|
+
# known, so this is « could not check ».
|
|
242
|
+
_list_entry(
|
|
243
|
+
bundles,
|
|
244
|
+
out,
|
|
245
|
+
path.name,
|
|
246
|
+
as_json=arguments.json,
|
|
247
|
+
reason=_could_not_check_entry(path, failure, arguments, err, codes),
|
|
248
|
+
)
|
|
249
|
+
continue
|
|
250
|
+
if not regular:
|
|
251
|
+
# A directory or a dangling link named like a bundle is listed,
|
|
252
|
+
# never dropped: silence here would read as « no such file ».
|
|
253
|
+
_list_entry(bundles, out, path.name, as_json=arguments.json, reason=None)
|
|
254
|
+
continue
|
|
255
|
+
try:
|
|
256
|
+
data = path.read_bytes()
|
|
257
|
+
except OSError as failure:
|
|
258
|
+
# Unreadable is not « not a bundle »: the file may well be one, and
|
|
259
|
+
# « I could not read it » is not a negative fact (article 2).
|
|
260
|
+
_list_entry(
|
|
261
|
+
bundles,
|
|
262
|
+
out,
|
|
263
|
+
path.name,
|
|
264
|
+
as_json=arguments.json,
|
|
265
|
+
reason=_could_not_check_entry(path, failure, arguments, err, codes),
|
|
266
|
+
)
|
|
267
|
+
continue
|
|
268
|
+
try:
|
|
269
|
+
# Bytes, so a file that is not even UTF-8 is « not a bundle »
|
|
270
|
+
# (json.loads raises ValueError for it) rather than an escape.
|
|
271
|
+
bundle = json.loads(data)
|
|
272
|
+
except (ValueError, RecursionError):
|
|
273
|
+
bundle = None
|
|
274
|
+
if bundle is not None and reads.too_deep(bundle):
|
|
275
|
+
bundle = None
|
|
276
|
+
if not isinstance(bundle, Mapping) or not {
|
|
277
|
+
"scope",
|
|
278
|
+
"from_sequence",
|
|
279
|
+
"entries",
|
|
280
|
+
"manifest_hash",
|
|
281
|
+
}.issubset(bundle):
|
|
282
|
+
_list_entry(bundles, out, path.name, as_json=arguments.json, reason=None)
|
|
283
|
+
continue
|
|
284
|
+
try:
|
|
285
|
+
local = verify_export(bundle)
|
|
286
|
+
except reads.INPUT_ERRORS as failure:
|
|
287
|
+
reason = _could_not_check_entry(path, failure, arguments, err, codes)
|
|
288
|
+
local = None
|
|
289
|
+
else:
|
|
290
|
+
codes.add(exit_for(local))
|
|
291
|
+
reason = None
|
|
292
|
+
served = bundle.get("verification")
|
|
293
|
+
item: dict[str, object] = {
|
|
294
|
+
"file": path.name,
|
|
295
|
+
"scope": bundle["scope"],
|
|
296
|
+
"from_sequence": bundle["from_sequence"],
|
|
297
|
+
"to_sequence": bundle.get("to_sequence"),
|
|
298
|
+
"served": served,
|
|
299
|
+
"local_check": local.to_document() if local is not None else None,
|
|
300
|
+
}
|
|
301
|
+
if reason is not None:
|
|
302
|
+
item["could_not_check"] = reason
|
|
303
|
+
bundles.append(item)
|
|
304
|
+
if not arguments.json:
|
|
305
|
+
to_sequence = bundle.get("to_sequence")
|
|
306
|
+
condition = served.get("condition") if isinstance(served, Mapping) else None
|
|
307
|
+
overall = local.overall if local is not None else render.NOT_STATED
|
|
308
|
+
out.write(
|
|
309
|
+
f"{path.name} {bundle['scope']} {bundle['from_sequence']}.."
|
|
310
|
+
f"{to_sequence if to_sequence is not None else 'open'} "
|
|
311
|
+
f"served:{condition if condition is not None else 'none'} local:{overall}\n"
|
|
312
|
+
)
|
|
313
|
+
if arguments.json:
|
|
314
|
+
render.write_json(
|
|
315
|
+
render.envelope(
|
|
316
|
+
CONTRACT_GENERATION,
|
|
317
|
+
render.verification_document(None, None, False),
|
|
318
|
+
result={"directory": arguments.directory, "bundles": bundles},
|
|
319
|
+
),
|
|
320
|
+
out,
|
|
321
|
+
)
|
|
322
|
+
elif not bundles:
|
|
323
|
+
out.write(f"no bundles in {arguments.directory}\n")
|
|
324
|
+
if exit_codes.EXIT_CHECK_FAILED in codes:
|
|
325
|
+
return exit_codes.EXIT_CHECK_FAILED
|
|
326
|
+
if exit_codes.EXIT_COULD_NOT_CHECK in codes:
|
|
327
|
+
return exit_codes.EXIT_COULD_NOT_CHECK
|
|
328
|
+
return 0
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _merged_verdict(verdicts: Sequence[Mapping[str, object]]) -> dict[str, object]:
|
|
332
|
+
"""One served verdict for a read that spanned several pages, dropping nothing.
|
|
333
|
+
|
|
334
|
+
The daemon verifies each page over that page's own range, so a purge it
|
|
335
|
+
declared before a page's `from` cannot appear in that page's verdict.
|
|
336
|
+
Rendering only the last page's verdict therefore drops what the plane said
|
|
337
|
+
on every earlier one, and a declared drop rendered as a clean chain is an
|
|
338
|
+
absence rendered as a healthy state, which article 2 forbids.
|
|
339
|
+
|
|
340
|
+
So: declared gaps are concatenated in page order; a grade is kept per
|
|
341
|
+
connection, a later page's replacing an earlier one; and the condition is
|
|
342
|
+
`intact` only if every page said so. The first page that said otherwise is
|
|
343
|
+
the one whose verdict is reported, its `sequence`, `expected` and `found`
|
|
344
|
+
included, so that what is rendered beside the condition belongs to it.
|
|
345
|
+
|
|
346
|
+
Every member of what comes back is a member the plane's own verifications
|
|
347
|
+
carry. How many pages the answer is made of is this client's fact, not the
|
|
348
|
+
plane's, so it is reported beside this document and never inside it — a
|
|
349
|
+
member of `served` reads as something the daemon said (article 2).
|
|
350
|
+
"""
|
|
351
|
+
base = next(
|
|
352
|
+
(verdict for verdict in verdicts if verdict["condition"] != ChainCondition.intact.value),
|
|
353
|
+
verdicts[-1],
|
|
354
|
+
)
|
|
355
|
+
gaps: list[object] = []
|
|
356
|
+
grades: dict[object, object] = {}
|
|
357
|
+
for verdict in verdicts:
|
|
358
|
+
gaps.extend(verdict["declared_gaps"])
|
|
359
|
+
for grade in verdict["grades"]:
|
|
360
|
+
grades[grade["connection_id"]] = grade
|
|
361
|
+
merged: dict[str, object] = {**base, "declared_gaps": gaps, "grades": list(grades.values())}
|
|
362
|
+
# The range members describe the whole read, not the page the condition
|
|
363
|
+
# came from: a gap declared at 2 inside a verdict claiming « from 3 » would
|
|
364
|
+
# be a document no daemon sent, contradicting itself.
|
|
365
|
+
# `up_to` stays with `base`: it means « verified intact through here » on
|
|
366
|
+
# the page whose condition is reported, not the read's end.
|
|
367
|
+
if "from_sequence" in base:
|
|
368
|
+
merged["from_sequence"] = verdicts[0].get("from_sequence")
|
|
369
|
+
if "to_sequence" in base:
|
|
370
|
+
merged["to_sequence"] = next(
|
|
371
|
+
(v["to_sequence"] for v in reversed(verdicts) if v.get("to_sequence") is not None),
|
|
372
|
+
None,
|
|
373
|
+
)
|
|
374
|
+
if "covers_an_entry" in base:
|
|
375
|
+
merged["covers_an_entry"] = any(v.get("covers_an_entry") is True for v in verdicts)
|
|
376
|
+
return merged
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _write_entries(entries: Sequence[Mapping[str, object]], out: TextIO) -> None:
|
|
380
|
+
for entry in entries:
|
|
381
|
+
out.write(
|
|
382
|
+
f"{entry['sequence']} {entry['kind']} {entry['connection_id']} {entry['entry_hash']}\n"
|
|
383
|
+
)
|
|
384
|
+
out.flush()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _write_history(document: Mapping[str, object], out: TextIO) -> None:
|
|
388
|
+
# Entries have already been written as each page arrived.
|
|
389
|
+
next_from = document["pages"][-1]["next_from"]
|
|
390
|
+
out.write(f"next_from: {'none' if next_from is None else next_from}\n")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _write_audit(document: Mapping[str, object], out: TextIO) -> None:
|
|
394
|
+
served, local = document["served"], document["local_check"]
|
|
395
|
+
out.write(f"verification: {served['condition']}\n")
|
|
396
|
+
if served.get("sequence") is not None:
|
|
397
|
+
out.write(f"verification.sequence: {served['sequence']}\n")
|
|
398
|
+
# A verdict merged from several pages says how many, on a line of its own:
|
|
399
|
+
# « intact » over one page and over nine are not the same claim, and the
|
|
400
|
+
# count is this client's, so it is not written as a member of the verdict.
|
|
401
|
+
# One page needs no count, and the JSON envelope carries `pages` either way.
|
|
402
|
+
if document["pages"] > 1:
|
|
403
|
+
out.write(f"pages: {document['pages']}\n")
|
|
404
|
+
out.write(f"local_check: {local['condition']}\n")
|
|
405
|
+
# `version` belongs beside the other three: a chain the verifier could not
|
|
406
|
+
# judge because it holds no reader for the recipe an entry declares names
|
|
407
|
+
# that recipe here, and nowhere else in the prose. Without it the line
|
|
408
|
+
# reads « unverifiable » and leaves the reader nothing to go and find.
|
|
409
|
+
for member in ("sequence", "expected", "found", "version"):
|
|
410
|
+
if local.get(member) is not None:
|
|
411
|
+
out.write(f"local_check.{member}: {local[member]}\n")
|
|
412
|
+
for gap in served["declared_gaps"]:
|
|
413
|
+
out.write(f"gap: sequence {gap['sequence']} reason {gap['reason']} count {gap['count']}\n")
|
|
414
|
+
for grade in served["grades"]:
|
|
415
|
+
out.write(f"grade: {grade['connection_id']} {grade['grade']}\n")
|
|
416
|
+
if served["condition"] != local["condition"]:
|
|
417
|
+
out.write(
|
|
418
|
+
f"finding: the plane says {served['condition']}, "
|
|
419
|
+
f"the local check says {local['condition']}\n"
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _online(arguments: argparse.Namespace, out: TextIO, err: TextIO, *, history: bool) -> int:
|
|
424
|
+
connection = reads.open_connection(arguments, err)
|
|
425
|
+
if isinstance(connection, int):
|
|
426
|
+
return connection
|
|
427
|
+
from_sequence = arguments.from_sequence
|
|
428
|
+
render_answer = _write_history if history else _write_audit
|
|
429
|
+
code = 0
|
|
430
|
+
|
|
431
|
+
def walk() -> Result[Mapping[str, object]]:
|
|
432
|
+
"""Follow the continuations the daemon gives, and conclude once."""
|
|
433
|
+
nonlocal from_sequence, code
|
|
434
|
+
read_pages: list[Mapping[str, object]] = []
|
|
435
|
+
entries: list[Mapping[str, object]] = []
|
|
436
|
+
verdicts: list[Mapping[str, object]] = []
|
|
437
|
+
while True:
|
|
438
|
+
result = connection.read_evidence(arguments.scope, from_sequence, arguments.page_size)
|
|
439
|
+
if not isinstance(result, Answered):
|
|
440
|
+
return result
|
|
441
|
+
# One depth rule, and it is read HERE, before a line of prose is
|
|
442
|
+
# written: `pages.members` bounds this page at `PAGE_DEPTH_LIMIT`,
|
|
443
|
+
# which is the render's own bound less the two levels this walk's
|
|
444
|
+
# answer adds around a page. So a page it accepts is a page this
|
|
445
|
+
# command can render, and a page it refuses is refused before
|
|
446
|
+
# `_write_entries` has put anything on the caller's stdout —
|
|
447
|
+
# which is what the half-render was.
|
|
448
|
+
page_entries, served, next_from = pages.members(result.value, from_sequence)
|
|
449
|
+
if history:
|
|
450
|
+
read_pages.append(result.value)
|
|
451
|
+
if not arguments.json:
|
|
452
|
+
_write_entries(page_entries, out)
|
|
453
|
+
else:
|
|
454
|
+
verdicts.append(served)
|
|
455
|
+
entries.extend(
|
|
456
|
+
entry
|
|
457
|
+
for entry in page_entries
|
|
458
|
+
if arguments.to_sequence is None or entry["sequence"] <= arguments.to_sequence
|
|
459
|
+
)
|
|
460
|
+
if (
|
|
461
|
+
next_from is None
|
|
462
|
+
or (history and not arguments.all)
|
|
463
|
+
or (
|
|
464
|
+
not history
|
|
465
|
+
and arguments.to_sequence is not None
|
|
466
|
+
and (
|
|
467
|
+
next_from > arguments.to_sequence
|
|
468
|
+
or (page_entries and page_entries[-1]["sequence"] >= arguments.to_sequence)
|
|
469
|
+
)
|
|
470
|
+
)
|
|
471
|
+
):
|
|
472
|
+
if history:
|
|
473
|
+
return Answered({"pages": read_pages}, CONTRACT_GENERATION)
|
|
474
|
+
local = verify_chain(
|
|
475
|
+
entries,
|
|
476
|
+
scope=arguments.scope,
|
|
477
|
+
from_sequence=arguments.from_sequence,
|
|
478
|
+
to_sequence=arguments.to_sequence,
|
|
479
|
+
)
|
|
480
|
+
merged = _merged_verdict(verdicts)
|
|
481
|
+
code = exit_for(local)
|
|
482
|
+
if merged["condition"] != local.condition:
|
|
483
|
+
code = max(code, exit_codes.EXIT_CHECK_FAILED)
|
|
484
|
+
return Answered(
|
|
485
|
+
{
|
|
486
|
+
"served": merged,
|
|
487
|
+
"local_check": local.to_document(),
|
|
488
|
+
"pages": len(verdicts),
|
|
489
|
+
},
|
|
490
|
+
CONTRACT_GENERATION,
|
|
491
|
+
)
|
|
492
|
+
from_sequence = next_from
|
|
493
|
+
# An evidence read may close its HTTP connection. Reopening is
|
|
494
|
+
# explicit and verifies the far end again, as rule C4 requires.
|
|
495
|
+
connection.reconnect()
|
|
496
|
+
|
|
497
|
+
try:
|
|
498
|
+
result = reads.read(walk)
|
|
499
|
+
if isinstance(result, Answered):
|
|
500
|
+
rendered = reads.finish(result, arguments, connection, out, err, render_answer)
|
|
501
|
+
# `finish` bounds the document it renders: one it could not render
|
|
502
|
+
# is its own non-answer, not this read's local check.
|
|
503
|
+
return code if rendered == 0 else rendered
|
|
504
|
+
problem = replace(
|
|
505
|
+
result.problem,
|
|
506
|
+
message=f"stopped at from_sequence {from_sequence}: {result.problem.message}",
|
|
507
|
+
)
|
|
508
|
+
result = Refused(problem) if isinstance(result, Refused) else CouldNotAsk(problem)
|
|
509
|
+
return reads.finish(result, arguments, connection, out, err, render_answer)
|
|
510
|
+
finally:
|
|
511
|
+
connection.close()
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _offline(arguments: argparse.Namespace, out: TextIO, err: TextIO) -> int:
|
|
515
|
+
verification = render.verification_document(None, None, False)
|
|
516
|
+
try:
|
|
517
|
+
bundle = json.loads(arguments.file.read_text(encoding="utf-8"))
|
|
518
|
+
if reads.too_deep(bundle):
|
|
519
|
+
raise ValueError(
|
|
520
|
+
f"nested deeper than {reads.DOCUMENT_DEPTH_LIMIT} levels; "
|
|
521
|
+
"not a bundle this client reads"
|
|
522
|
+
)
|
|
523
|
+
# What a bundle's CONTENT says about the chain is a verdict, never an
|
|
524
|
+
# exception: a range carrying an entry of another scope, a recipe this
|
|
525
|
+
# wheel holds no reader for, an instant outside the calendar are each
|
|
526
|
+
# `unverifiable` at the sequence that could not be judged. So this
|
|
527
|
+
# `except` is about the FILE and this reader — one that will not open,
|
|
528
|
+
# will not parse, or nests past the bound — and it stays because a
|
|
529
|
+
# traceback out of here would exit 1, the code for « denied ».
|
|
530
|
+
local = verify_export(bundle)
|
|
531
|
+
except (OSError, *reads.INPUT_ERRORS) as failure:
|
|
532
|
+
return _could_not_check(arguments.file, failure, arguments.json, err)
|
|
533
|
+
served = bundle.get("verification") if isinstance(bundle, Mapping) else None
|
|
534
|
+
if arguments.json:
|
|
535
|
+
render.write_json(
|
|
536
|
+
render.envelope(
|
|
537
|
+
CONTRACT_GENERATION,
|
|
538
|
+
verification,
|
|
539
|
+
result={"served": served, "local_check": local.to_document()},
|
|
540
|
+
),
|
|
541
|
+
out,
|
|
542
|
+
)
|
|
543
|
+
else:
|
|
544
|
+
_write_export_check(bundle, local, out)
|
|
545
|
+
return exit_for(local)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _list_entry(
|
|
549
|
+
bundles: list[dict[str, object]],
|
|
550
|
+
out: TextIO,
|
|
551
|
+
name: str,
|
|
552
|
+
*,
|
|
553
|
+
as_json: bool,
|
|
554
|
+
reason: str | None,
|
|
555
|
+
) -> None:
|
|
556
|
+
"""One listed entry that holds no verdict, said the same way in both modes.
|
|
557
|
+
|
|
558
|
+
`reason` is what stopped the check, or `None` where there is nothing to
|
|
559
|
+
check: « not a bundle » is a fact about the file, « could not check » is
|
|
560
|
+
this client's own inability, and article 2 keeps them apart.
|
|
561
|
+
"""
|
|
562
|
+
if reason is None:
|
|
563
|
+
bundles.append({"file": name, "not_a_bundle": True})
|
|
564
|
+
else:
|
|
565
|
+
bundles.append({"file": name, "could_not_check": reason})
|
|
566
|
+
if not as_json:
|
|
567
|
+
out.write(f"{name} {'not a bundle' if reason is None else 'could not check'}\n")
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _could_not_check_entry(
|
|
571
|
+
path: Path, failure: Exception, arguments: argparse.Namespace, err: TextIO, codes: set[int]
|
|
572
|
+
) -> str:
|
|
573
|
+
"""One entry of a listing could not be checked: 7 for the whole run.
|
|
574
|
+
|
|
575
|
+
The reason goes to stderr in prose; in JSON it travels in the entry's own
|
|
576
|
+
item, so stdout stays one parseable envelope however many entries fail.
|
|
577
|
+
"""
|
|
578
|
+
codes.add(exit_codes.EXIT_COULD_NOT_CHECK)
|
|
579
|
+
if not arguments.json:
|
|
580
|
+
err.write(f"could not check: {path}: {failure}\n")
|
|
581
|
+
return str(failure)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _could_not_check(
|
|
585
|
+
path: str | Path,
|
|
586
|
+
failure: Exception,
|
|
587
|
+
as_json: bool,
|
|
588
|
+
err: TextIO,
|
|
589
|
+
*,
|
|
590
|
+
verification: Mapping[str, object] | None = None,
|
|
591
|
+
) -> int:
|
|
592
|
+
"""The check could not run at all; offline there is nothing verified to report."""
|
|
593
|
+
message = f"could not check: {path}: {failure}"
|
|
594
|
+
if as_json:
|
|
595
|
+
problem = Problem(
|
|
596
|
+
ProblemCode.ANSWER_UNREADABLE,
|
|
597
|
+
message,
|
|
598
|
+
problem_retryable(ProblemCode.ANSWER_UNREADABLE),
|
|
599
|
+
CONTRACT_GENERATION,
|
|
600
|
+
)
|
|
601
|
+
render.write_json(
|
|
602
|
+
render.envelope(
|
|
603
|
+
CONTRACT_GENERATION,
|
|
604
|
+
verification
|
|
605
|
+
if verification is not None
|
|
606
|
+
else render.verification_document(None, None, False),
|
|
607
|
+
problem=problem.to_document(),
|
|
608
|
+
),
|
|
609
|
+
err,
|
|
610
|
+
)
|
|
611
|
+
else:
|
|
612
|
+
err.write(f"{message}\n")
|
|
613
|
+
return exit_codes.EXIT_COULD_NOT_CHECK
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _write_export_check(bundle: object, local: ExportVerdict, out: TextIO) -> None:
|
|
617
|
+
"""The same offline check accompanies an audit and a newly saved export."""
|
|
618
|
+
out.write(f"local_check: {local.overall}\n")
|
|
619
|
+
if local.manifest_hash_recomputes is None:
|
|
620
|
+
version = bundle.get("manifest_version") if isinstance(bundle, Mapping) else None
|
|
621
|
+
manifest = f"unknown version {version if version is not None else render.NOT_STATED}"
|
|
622
|
+
else:
|
|
623
|
+
manifest = "recomputes" if local.manifest_hash_recomputes else "does not recompute"
|
|
624
|
+
out.write(f"manifest: {manifest}\n")
|
|
625
|
+
out.write(f"chain: {local.chain.condition if local.chain is not None else render.NOT_STATED}\n")
|
|
626
|
+
if local.chain is not None:
|
|
627
|
+
# Where the range stopped being what it claims, and — for a chain this
|
|
628
|
+
# verifier could not judge — which recipe a reader has to go and find.
|
|
629
|
+
# `unverifiable` without them is « something is wrong somewhere », which
|
|
630
|
+
# is the shape of an absence stated as a fact (article 2).
|
|
631
|
+
for member in ("sequence", "expected", "found", "version"):
|
|
632
|
+
said = getattr(local.chain, member, None)
|
|
633
|
+
if said is not None:
|
|
634
|
+
out.write(f"chain.{member}: {said}\n")
|
|
635
|
+
out.write(f"coverage: {local.coverage}\n")
|
|
636
|
+
for issue in local.issues:
|
|
637
|
+
out.write(f"issue: {issue}\n")
|
|
638
|
+
served = bundle.get("verification") if isinstance(bundle, Mapping) else None
|
|
639
|
+
condition = served.get("condition") if isinstance(served, Mapping) else None
|
|
640
|
+
out.write(f"verification: {condition if condition is not None else render.NOT_STATED}\n")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
COMMANDS: Final[dict[str, Callable[..., int]]] = {
|
|
644
|
+
"history": _history,
|
|
645
|
+
"audit": _audit,
|
|
646
|
+
"export": _export,
|
|
647
|
+
"exports": _exports,
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
652
|
+
parser = argparse.ArgumentParser(
|
|
653
|
+
prog="sayfirst evidence", description="Read and check evidence."
|
|
654
|
+
)
|
|
655
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
656
|
+
for name in COMMANDS:
|
|
657
|
+
commands.add_parser(name, add_help=False)
|
|
658
|
+
return parser
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def main(
|
|
662
|
+
argv: Sequence[str] | None = None, *, out: TextIO | None = None, err: TextIO | None = None
|
|
663
|
+
) -> int:
|
|
664
|
+
forwarded = list(sys.argv[1:] if argv is None else argv)
|
|
665
|
+
if forwarded and (command := COMMANDS.get(forwarded[0])) is not None:
|
|
666
|
+
return command(forwarded[1:], out=out or sys.stdout, err=err or sys.stderr)
|
|
667
|
+
build_parser().parse_args(forwarded)
|
|
668
|
+
raise AssertionError("argparse accepted an evidence command that has no entry point")
|