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.
@@ -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")