techtree 0.1.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.
- techtree/__init__.py +35 -0
- techtree/__main__.py +14 -0
- techtree/canonical.py +239 -0
- techtree/catalog/__init__.py +25 -0
- techtree/catalog/repository.py +400 -0
- techtree/catalog/service.py +419 -0
- techtree/cli/__init__.py +1 -0
- techtree/cli/app.py +416 -0
- techtree/cli/commands/__init__.py +1 -0
- techtree/cli/commands/climb.py +1223 -0
- techtree/cli/commands/doctor.py +147 -0
- techtree/cli/commands/engine.py +207 -0
- techtree/cli/commands/proof.py +556 -0
- techtree/cli/commands/publish.py +447 -0
- techtree/cli/commands/release.py +303 -0
- techtree/cli/commands/run.py +1067 -0
- techtree/cli/commands/setup.py +181 -0
- techtree/cli/commands/skill.py +221 -0
- techtree/cli/commands/uplift.py +698 -0
- techtree/cli/commands/withdraw.py +212 -0
- techtree/cli/confirm.py +47 -0
- techtree/cli/context.py +96 -0
- techtree/cli/invoke.py +220 -0
- techtree/cli/output.py +280 -0
- techtree/constants.py +138 -0
- techtree/crypto.py +128 -0
- techtree/doctor/__init__.py +1 -0
- techtree/doctor/checks.py +675 -0
- techtree/doctor/execution_checks.py +435 -0
- techtree/doctor/service.py +326 -0
- techtree/drafts/__init__.py +32 -0
- techtree/drafts/source.py +146 -0
- techtree/drafts/store.py +992 -0
- techtree/engines/__init__.py +1 -0
- techtree/engines/bundle.py +251 -0
- techtree/engines/installer.py +679 -0
- techtree/engines/registry.py +235 -0
- techtree/engines/runner.py +170 -0
- techtree/errors.py +262 -0
- techtree/fs.py +234 -0
- techtree/harness.py +108 -0
- techtree/identity/__init__.py +41 -0
- techtree/identity/models.py +113 -0
- techtree/identity/service.py +199 -0
- techtree/identity/store.py +263 -0
- techtree/ids.py +85 -0
- techtree/manifests/__init__.py +39 -0
- techtree/manifests/builder.py +433 -0
- techtree/manifests/compare.py +376 -0
- techtree/models/__init__.py +282 -0
- techtree/models/base.py +201 -0
- techtree/models/campaign.py +484 -0
- techtree/models/catalog.py +227 -0
- techtree/models/cli.py +151 -0
- techtree/models/climb.py +254 -0
- techtree/models/data_policy.py +130 -0
- techtree/models/engine.py +156 -0
- techtree/models/episode_receipt.py +130 -0
- techtree/models/evaluation_backend.py +113 -0
- techtree/models/experiment.py +154 -0
- techtree/models/run.py +214 -0
- techtree/models/skill.py +156 -0
- techtree/models/uplift_report.py +158 -0
- techtree/models/validation.py +299 -0
- techtree/paths.py +116 -0
- techtree/presentation/__init__.py +31 -0
- techtree/presentation/build.py +1242 -0
- techtree/presentation/compact.py +246 -0
- techtree/presentation/evidence.py +169 -0
- techtree/presentation/models.py +358 -0
- techtree/presentation/rich.py +312 -0
- techtree/presentation/sanitize.py +156 -0
- techtree/publication/__init__.py +44 -0
- techtree/publication/address.py +180 -0
- techtree/publication/coordinates.py +26 -0
- techtree/publication/journal.py +212 -0
- techtree/publication/keccak.py +183 -0
- techtree/publication/models.py +209 -0
- techtree/publication/offer.py +35 -0
- techtree/publication/service.py +618 -0
- techtree/publication/transport.py +296 -0
- techtree/publication/verify.py +242 -0
- techtree/publication/withdraw.py +156 -0
- techtree/py.typed +0 -0
- techtree/receipts/__init__.py +52 -0
- techtree/receipts/bundle.py +578 -0
- techtree/receipts/compare.py +1065 -0
- techtree/receipts/episode.py +672 -0
- techtree/receipts/execution.py +630 -0
- techtree/receipts/observed.py +474 -0
- techtree/receipts/set.py +336 -0
- techtree/receipts/uplift.py +655 -0
- techtree/receipts/verify.py +1055 -0
- techtree/release/__init__.py +9 -0
- techtree/release/bootstrap.py +509 -0
- techtree/release/checks.py +376 -0
- techtree/release/document.py +125 -0
- techtree/release/generate.py +221 -0
- techtree/release/models.py +293 -0
- techtree/release/provenance.py +109 -0
- techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
- techtree/resources/catalog/catalog.json +32 -0
- techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
- techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
- techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
- techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
- techtree/resources/engines/default/engine.json +20 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
- techtree/resources/engines/default/pyproject.toml +23 -0
- techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
- techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
- techtree/resources/engines/default/tools/normalize_validation.py +222 -0
- techtree/resources/engines/default/uv.lock +1758 -0
- techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
- techtree/resources/release/build-provenance.json +4 -0
- techtree/resources/release/release-core.json +24 -0
- techtree/runs/__init__.py +31 -0
- techtree/runs/artifacts.py +750 -0
- techtree/runs/child_registry.py +228 -0
- techtree/runs/events.py +478 -0
- techtree/runs/executor.py +140 -0
- techtree/runs/fake.py +741 -0
- techtree/runs/launcher.py +253 -0
- techtree/runs/machine.py +489 -0
- techtree/runs/real.py +789 -0
- techtree/runs/service.py +616 -0
- techtree/runs/store.py +555 -0
- techtree/runs/validation.py +259 -0
- techtree/runs/variants.py +684 -0
- techtree/settings.py +143 -0
- techtree/skills/__init__.py +14 -0
- techtree/skills/archive.py +282 -0
- techtree/skills/policy.py +62 -0
- techtree/skills/scanner.py +394 -0
- techtree/skills/service.py +752 -0
- techtree/skills/starter.py +434 -0
- techtree/tasksets/__init__.py +1 -0
- techtree/tasksets/membership.py +269 -0
- techtree/tasksets/provider.py +207 -0
- techtree/tasksets/resolver.py +311 -0
- techtree/tasksets/service.py +484 -0
- techtree/tasksets/verifiers_cli.py +538 -0
- techtree/uplift/__init__.py +20 -0
- techtree/uplift/context.py +544 -0
- techtree/uplift/derive.py +203 -0
- techtree/uplift/public_tasks.py +151 -0
- techtree/uplift/service.py +719 -0
- techtree/uplift/source.py +160 -0
- techtree/verifiers/__init__.py +31 -0
- techtree/verifiers/budget.py +219 -0
- techtree/verifiers/child.py +633 -0
- techtree/verifiers/compiler.py +432 -0
- techtree/verifiers/config.py +365 -0
- techtree/verifiers/credentials.py +321 -0
- techtree/verifiers/image.py +126 -0
- techtree/verifiers/models.py +527 -0
- techtree/verifiers/outputs.py +368 -0
- techtree/verifiers/progress.py +192 -0
- techtree/verifiers/supervisor.py +341 -0
- techtree/verifiers/verify.py +782 -0
- techtree/version.py +39 -0
- techtree/worker/__init__.py +18 -0
- techtree/worker/execute.py +487 -0
- techtree/worker/main.py +57 -0
- techtree-0.1.0.dist-info/METADATA +344 -0
- techtree-0.1.0.dist-info/RECORD +174 -0
- techtree-0.1.0.dist-info/WHEEL +4 -0
- techtree-0.1.0.dist-info/entry_points.txt +3 -0
- techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
"""``techtree proof verify``. Spec sections 7.12 and 7.21.
|
|
2
|
+
|
|
3
|
+
One command, and it answers one question about a directory of files: does this
|
|
4
|
+
proof still hold together? It reads, hashes, and checks signatures, and it
|
|
5
|
+
writes nothing, contacts nothing, and needs no Techtree state of its own — a
|
|
6
|
+
person handed a proof bundle on a memory stick can check it on a machine that
|
|
7
|
+
has never run a Climb.
|
|
8
|
+
|
|
9
|
+
The human rendering keeps five things apart, because collapsing them is how
|
|
10
|
+
"the signature verifies" turns into "the result is proven":
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
cryptographic integrity the files still match what was signed
|
|
14
|
+
scientific validity the documents describe one controlled comparison
|
|
15
|
+
participant attestation whose key vouched for them, and what that means
|
|
16
|
+
independent reproduction nobody has done it
|
|
17
|
+
public publication what this proof's own record says, and
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
A failed verification is a typed failure with exit code 11 and the failed
|
|
21
|
+
checks in the envelope, not a printed warning, because a caller that scripts
|
|
22
|
+
this is deciding whether to believe a number.
|
|
23
|
+
|
|
24
|
+
Two audiences read the same result and need opposite things from it. A machine
|
|
25
|
+
gets every check under its own stable identifier, and those identifiers are
|
|
26
|
+
named for the failure each check reports, because that is the vocabulary a
|
|
27
|
+
caller branches on. A person reading a proof that holds together does not need
|
|
28
|
+
three hundred rows each headed by the name of something that did not happen; a
|
|
29
|
+
check that passed is worth counting, and what a reader wants counted is the
|
|
30
|
+
kind of thing it confirmed. So the human rendering groups the checks under
|
|
31
|
+
headings it derives here, at the moment of printing, and prints the full list
|
|
32
|
+
only when it is asked for. A check that failed is the other way round entirely:
|
|
33
|
+
it keeps its exact identifier and its exact code, and gains the heading and the
|
|
34
|
+
subject that say where in the proof the trouble is.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from collections.abc import Callable, Sequence
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Annotated, Final, Literal
|
|
42
|
+
|
|
43
|
+
import typer
|
|
44
|
+
from rich.console import Console
|
|
45
|
+
from rich.table import Table
|
|
46
|
+
from rich.text import Text
|
|
47
|
+
|
|
48
|
+
from techtree.cli.commands.publish import build_publication_service
|
|
49
|
+
from techtree.cli.context import CliContext, cli_context
|
|
50
|
+
from techtree.cli.invoke import CommandResult, invoke_command
|
|
51
|
+
from techtree.cli.output import DataRenderer
|
|
52
|
+
from techtree.errors import NotFoundError, ValidationError, VerificationError
|
|
53
|
+
from techtree.identity.models import VerificationMessage, VerificationResult
|
|
54
|
+
from techtree.ids import validate_id
|
|
55
|
+
from techtree.models.base import JsonValue, NonEmptyString, ProtocolModel
|
|
56
|
+
from techtree.models.cli import CliMessage, MessageLevel, NextAction
|
|
57
|
+
from techtree.publication.offer import publish_action
|
|
58
|
+
from techtree.receipts.bundle import (
|
|
59
|
+
BUNDLE_MANIFEST_FILENAME,
|
|
60
|
+
PROOF_BUNDLE_INVALID,
|
|
61
|
+
proof_bundle_dir,
|
|
62
|
+
)
|
|
63
|
+
from techtree.receipts.verify import LocalProofVerifier
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"PROOF_TARGET_NOT_FOUND",
|
|
67
|
+
"VERIFY_COMMAND",
|
|
68
|
+
"ProofVerificationPayload",
|
|
69
|
+
"resolve_proof_target",
|
|
70
|
+
"verify_proof_command",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
VERIFY_COMMAND: Final = "proof verify"
|
|
74
|
+
|
|
75
|
+
#: Stable error code for "there is nothing at that name to verify". Distinct
|
|
76
|
+
#: from a bundle that exists and does not hold together, which is the section
|
|
77
|
+
#: 15 ``proof_bundle_invalid``.
|
|
78
|
+
PROOF_TARGET_NOT_FOUND: Final = "proof_target_not_found"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ProofVerificationPayload(ProtocolModel):
|
|
82
|
+
"""What was verified, and every check that was run on it."""
|
|
83
|
+
|
|
84
|
+
target: NonEmptyString
|
|
85
|
+
kind: Literal["bundle", "report"]
|
|
86
|
+
verified: bool
|
|
87
|
+
summary: list[VerificationMessage]
|
|
88
|
+
checks: list[VerificationMessage]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def verify_proof_command(
|
|
92
|
+
ctx: typer.Context,
|
|
93
|
+
target: Annotated[
|
|
94
|
+
str,
|
|
95
|
+
typer.Argument(
|
|
96
|
+
metavar="TARGET",
|
|
97
|
+
help=(
|
|
98
|
+
"A run identifier, a proof bundle directory, or a signed "
|
|
99
|
+
"uplift-report file."
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
],
|
|
103
|
+
every_check: Annotated[
|
|
104
|
+
bool,
|
|
105
|
+
typer.Option(
|
|
106
|
+
"--checks",
|
|
107
|
+
help=(
|
|
108
|
+
"List every check that ran and what it confirmed, instead of "
|
|
109
|
+
"the counts."
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
] = False,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Check a local proof, offline, from the bytes it stored."""
|
|
115
|
+
context = cli_context(ctx)
|
|
116
|
+
|
|
117
|
+
def action() -> CommandResult[ProofVerificationPayload]:
|
|
118
|
+
path, kind = resolve_proof_target(target, runs_dir=context.paths.runs_dir)
|
|
119
|
+
verifier = LocalProofVerifier()
|
|
120
|
+
result = (
|
|
121
|
+
verifier.verify_bundle(path)
|
|
122
|
+
if kind == "bundle"
|
|
123
|
+
else verifier.verify_report(path)
|
|
124
|
+
)
|
|
125
|
+
payload = ProofVerificationPayload(
|
|
126
|
+
target=target,
|
|
127
|
+
kind=kind,
|
|
128
|
+
verified=result.verified,
|
|
129
|
+
summary=verifier.explain(result),
|
|
130
|
+
checks=list(result.messages),
|
|
131
|
+
)
|
|
132
|
+
return CommandResult(
|
|
133
|
+
data=payload,
|
|
134
|
+
messages=_messages(payload),
|
|
135
|
+
warnings=_warnings(result),
|
|
136
|
+
next_actions=[
|
|
137
|
+
*_publication_offer(context, path, kind, verified=result.verified),
|
|
138
|
+
_read_logs(target),
|
|
139
|
+
],
|
|
140
|
+
error=None if result.verified else _failure(payload, result),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
invoke_command(
|
|
144
|
+
context,
|
|
145
|
+
VERIFY_COMMAND,
|
|
146
|
+
action,
|
|
147
|
+
render_data=_renderer(every_check=every_check),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def resolve_proof_target(
|
|
152
|
+
target: str, *, runs_dir: Path
|
|
153
|
+
) -> tuple[Path, Literal["bundle", "report"]]:
|
|
154
|
+
"""Turn what a caller typed into a directory or a file to verify.
|
|
155
|
+
|
|
156
|
+
Three spellings are accepted (spec section 7.21) and each one is decided by
|
|
157
|
+
what is actually there rather than by how it looks: a directory is a
|
|
158
|
+
bundle, a file is a signed report, and anything else is read as a run
|
|
159
|
+
identifier and looked up in this machine's runs.
|
|
160
|
+
"""
|
|
161
|
+
candidate = Path(target).expanduser()
|
|
162
|
+
if candidate.is_dir():
|
|
163
|
+
return candidate, "bundle"
|
|
164
|
+
if candidate.is_file():
|
|
165
|
+
if candidate.name == BUNDLE_MANIFEST_FILENAME:
|
|
166
|
+
return candidate.parent, "bundle"
|
|
167
|
+
return candidate, "report"
|
|
168
|
+
|
|
169
|
+
missing = NotFoundError(
|
|
170
|
+
f"there is no proof to verify for {target}: no such run, directory or file",
|
|
171
|
+
code=PROOF_TARGET_NOT_FOUND,
|
|
172
|
+
details={"target": target},
|
|
173
|
+
)
|
|
174
|
+
try:
|
|
175
|
+
run_id = validate_id(target, "run")
|
|
176
|
+
except ValidationError as error:
|
|
177
|
+
raise missing from error
|
|
178
|
+
|
|
179
|
+
directory = proof_bundle_dir(runs_dir / run_id)
|
|
180
|
+
if directory.is_dir():
|
|
181
|
+
return directory, "bundle"
|
|
182
|
+
raise missing
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# Saying what happened
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _failure(
|
|
191
|
+
payload: ProofVerificationPayload, result: VerificationResult
|
|
192
|
+
) -> VerificationError:
|
|
193
|
+
"""Return the typed failure a broken proof reports."""
|
|
194
|
+
return VerificationError(
|
|
195
|
+
f"this local proof does not verify: {result.failures[0].detail}",
|
|
196
|
+
code=PROOF_BUNDLE_INVALID,
|
|
197
|
+
details={
|
|
198
|
+
"target": payload.target,
|
|
199
|
+
"failed_checks": _identifiers(result),
|
|
200
|
+
"codes": _codes(result),
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _identifiers(result: VerificationResult) -> list[JsonValue]:
|
|
206
|
+
"""Return the failed checks in the shape a typed error's details carry."""
|
|
207
|
+
return [message.id for message in result.failures]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _codes(result: VerificationResult) -> list[JsonValue]:
|
|
211
|
+
"""Return the distinct section 15 codes a failed verification reports under."""
|
|
212
|
+
return [code for code in sorted({message.code for message in result.failures})]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _messages(payload: ProofVerificationPayload) -> list[CliMessage]:
|
|
216
|
+
if not payload.verified:
|
|
217
|
+
return []
|
|
218
|
+
return [
|
|
219
|
+
CliMessage(
|
|
220
|
+
level=MessageLevel.INFO,
|
|
221
|
+
code="proof_verified",
|
|
222
|
+
text=(
|
|
223
|
+
f"This proof verifies: {len(payload.checks)} checks, all from "
|
|
224
|
+
"the stored bytes, with nothing fetched."
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _warnings(result: VerificationResult) -> list[CliMessage]:
|
|
231
|
+
return [
|
|
232
|
+
CliMessage(
|
|
233
|
+
level=MessageLevel.WARNING,
|
|
234
|
+
code=message.code,
|
|
235
|
+
text=message.detail,
|
|
236
|
+
)
|
|
237
|
+
for message in result.warnings
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _publication_offer(
|
|
242
|
+
context: CliContext,
|
|
243
|
+
path: Path,
|
|
244
|
+
kind: Literal["bundle", "report"],
|
|
245
|
+
*,
|
|
246
|
+
verified: bool,
|
|
247
|
+
) -> list[NextAction]:
|
|
248
|
+
"""Return the offer to publish, for the one case it belongs to.
|
|
249
|
+
|
|
250
|
+
Three things have to be true at once. The proof has to have verified here,
|
|
251
|
+
just now — a check that failed is never followed by an invitation to send
|
|
252
|
+
the thing that failed it. It has to be a bundle belonging to a run on this
|
|
253
|
+
machine, because publishing takes a run and a directory somebody was handed
|
|
254
|
+
on a memory stick is not one. And the report has to say it may be published,
|
|
255
|
+
because offering a command that would refuse is worse than offering nothing.
|
|
256
|
+
"""
|
|
257
|
+
if not verified or kind != "bundle":
|
|
258
|
+
return []
|
|
259
|
+
run_id = _run_of(path, context.paths.runs_dir)
|
|
260
|
+
if run_id is None:
|
|
261
|
+
return []
|
|
262
|
+
if not build_publication_service(context).publication_eligible(run_id):
|
|
263
|
+
return []
|
|
264
|
+
return [publish_action(run_id)]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _run_of(bundle: Path, runs_dir: Path) -> str | None:
|
|
268
|
+
"""Return the run a proof directory belongs to, if it belongs to one.
|
|
269
|
+
|
|
270
|
+
Read off the directory rather than off what the caller typed, so a bundle
|
|
271
|
+
named by its path and the same bundle named by its run identifier are
|
|
272
|
+
offered the same thing.
|
|
273
|
+
"""
|
|
274
|
+
run_dir = bundle.parent
|
|
275
|
+
if run_dir.parent.resolve() != runs_dir.resolve():
|
|
276
|
+
return None
|
|
277
|
+
try:
|
|
278
|
+
return validate_id(run_dir.name, "run")
|
|
279
|
+
except ValidationError:
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _read_logs(target: str) -> NextAction:
|
|
284
|
+
return NextAction(
|
|
285
|
+
id="proof_checks",
|
|
286
|
+
label="See every check, including the ones that passed",
|
|
287
|
+
reason="Machine output lists each check with its own stable code.",
|
|
288
|
+
cli=["techtree", "proof", "verify", target, "--json"],
|
|
289
|
+
hermes_tool=None,
|
|
290
|
+
hermes_args=None,
|
|
291
|
+
requires_user_confirmation=False,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
# Turning identifiers into headings, at the moment of printing
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
type _Selector = Callable[[str], bool]
|
|
300
|
+
|
|
301
|
+
#: The tail every envelope check carries. These are the only checks whose own
|
|
302
|
+
#: sentence does not say what it was about — thirty-six receipts report the
|
|
303
|
+
#: same sentence — so the thing checked is read back off the identifier's head.
|
|
304
|
+
_SIGNATURE_ASPECTS: Final = (
|
|
305
|
+
".payload_digest",
|
|
306
|
+
".signature",
|
|
307
|
+
".signature_key",
|
|
308
|
+
".signature_present",
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
_ARTIFACT_PREFIX: Final = "artifact."
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _about_a_missing_file(identifier: str) -> bool:
|
|
315
|
+
return (
|
|
316
|
+
identifier.endswith(".present")
|
|
317
|
+
or identifier.startswith("document.")
|
|
318
|
+
or identifier == "bundle.public_key"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _about_a_stored_digest(identifier: str) -> bool:
|
|
323
|
+
return (
|
|
324
|
+
identifier.startswith(_ARTIFACT_PREFIX)
|
|
325
|
+
or identifier == "bundle.root_report_digest"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _about_linkage(identifier: str) -> bool:
|
|
330
|
+
return identifier.startswith(("linkage.", "receipt_set.", "execution_record."))
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _about_a_signature(identifier: str) -> bool:
|
|
334
|
+
return identifier.endswith(_SIGNATURE_ASPECTS)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
#: The headings a person reads a verification under, in the order the checks
|
|
338
|
+
#: were run. Each one says what its checks confirmed rather than what they
|
|
339
|
+
#: would have reported had they failed. The last heading takes whatever the
|
|
340
|
+
#: others left, so the counts always add up to everything that ran.
|
|
341
|
+
_HEADINGS: Final[tuple[tuple[str, _Selector], ...]] = (
|
|
342
|
+
("Files and key present", _about_a_missing_file),
|
|
343
|
+
("Stored file digests", _about_a_stored_digest),
|
|
344
|
+
("Linkage and control", _about_linkage),
|
|
345
|
+
("Signatures", _about_a_signature),
|
|
346
|
+
(
|
|
347
|
+
"Aggregate recomputation",
|
|
348
|
+
lambda identifier: identifier.startswith("aggregate."),
|
|
349
|
+
),
|
|
350
|
+
("Publication", lambda identifier: identifier.startswith("publication.")),
|
|
351
|
+
("Proof grade conditions", lambda identifier: identifier.startswith("p1.")),
|
|
352
|
+
("Other checks", lambda _identifier: True),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _grouped(
|
|
357
|
+
checks: Sequence[VerificationMessage],
|
|
358
|
+
) -> list[tuple[str, list[VerificationMessage]]]:
|
|
359
|
+
"""Return the checks under their headings, in reading order."""
|
|
360
|
+
collected: dict[str, list[VerificationMessage]] = {
|
|
361
|
+
heading: [] for heading, _ in _HEADINGS
|
|
362
|
+
}
|
|
363
|
+
for message in checks:
|
|
364
|
+
for heading, belongs_here in _HEADINGS:
|
|
365
|
+
if belongs_here(message.id):
|
|
366
|
+
collected[heading].append(message)
|
|
367
|
+
break
|
|
368
|
+
return [
|
|
369
|
+
(heading, collected[heading]) for heading, _ in _HEADINGS if collected[heading]
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _heading_of(identifier: str) -> str:
|
|
374
|
+
"""Return the heading one check is counted under."""
|
|
375
|
+
for heading, belongs_here in _HEADINGS:
|
|
376
|
+
if belongs_here(identifier):
|
|
377
|
+
return heading
|
|
378
|
+
raise AssertionError("the last heading takes every identifier")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _subject_of(message: VerificationMessage) -> str:
|
|
382
|
+
"""Return what one check was about, or nothing when its own words say so."""
|
|
383
|
+
for aspect in _SIGNATURE_ASPECTS:
|
|
384
|
+
if message.id.endswith(aspect):
|
|
385
|
+
return message.id[: -len(aspect)]
|
|
386
|
+
if message.id.startswith(_ARTIFACT_PREFIX):
|
|
387
|
+
return message.id[len(_ARTIFACT_PREFIX) :]
|
|
388
|
+
return ""
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _named_beside(message: VerificationMessage) -> str:
|
|
392
|
+
"""Return the subject to print beside a check, or nothing if it repeats.
|
|
393
|
+
|
|
394
|
+
Most checks open their own sentence with the thing they were about. The
|
|
395
|
+
envelope checks do not, because thirty-six receipts report the identical
|
|
396
|
+
sentence, and those are the ones worth naming.
|
|
397
|
+
"""
|
|
398
|
+
subject = _subject_of(message)
|
|
399
|
+
return "" if message.detail.startswith(subject) else subject
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _tally(checks: Sequence[VerificationMessage]) -> tuple[str, str]:
|
|
403
|
+
"""Return how one heading came out, and anything about it worth reading."""
|
|
404
|
+
passed = sum(1 for message in checks if message.status == "passed")
|
|
405
|
+
failed = sum(1 for message in checks if message.status == "failed")
|
|
406
|
+
weaker = len(checks) - passed - failed
|
|
407
|
+
if len(checks) == 1:
|
|
408
|
+
return checks[0].status, ""
|
|
409
|
+
notes = []
|
|
410
|
+
if failed:
|
|
411
|
+
notes.append(f"{failed} failed")
|
|
412
|
+
if weaker:
|
|
413
|
+
notes.append("1 warning" if weaker == 1 else f"{weaker} warnings")
|
|
414
|
+
return f"{passed}/{len(checks)}", ", ".join(notes)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
# Printing it
|
|
419
|
+
# ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _renderer(*, every_check: bool) -> DataRenderer:
|
|
423
|
+
"""Return the human rendering, with or without the full list of checks."""
|
|
424
|
+
|
|
425
|
+
def render(data: object, console: Console) -> None:
|
|
426
|
+
_render(data, console, every_check=every_check)
|
|
427
|
+
|
|
428
|
+
return render
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _render(data: object, console: Console, *, every_check: bool) -> None:
|
|
432
|
+
if not isinstance(data, ProofVerificationPayload):
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
console.print(f"Proof: {data.target}")
|
|
436
|
+
console.print()
|
|
437
|
+
_render_summary(data.summary, console)
|
|
438
|
+
|
|
439
|
+
headings = _grouped(data.checks)
|
|
440
|
+
_render_counts(headings, len(data.checks), console)
|
|
441
|
+
if every_check:
|
|
442
|
+
_render_every_check(headings, console)
|
|
443
|
+
else:
|
|
444
|
+
console.print()
|
|
445
|
+
console.print("Add --checks to see every one of them and what it confirmed.")
|
|
446
|
+
|
|
447
|
+
failures = [message for message in data.checks if message.status == "failed"]
|
|
448
|
+
if failures:
|
|
449
|
+
_render_failures(failures, len(data.checks), console)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
#: The colours Doctor already gives a check outcome, so a reader who has read
|
|
453
|
+
#: one of the two surfaces can read the other without learning a second
|
|
454
|
+
#: vocabulary. A warning is not a failure and must not look like one.
|
|
455
|
+
_STATUS_STYLE: Final[dict[str, str]] = {
|
|
456
|
+
"passed": "green",
|
|
457
|
+
"warning": "yellow",
|
|
458
|
+
"failed": "red",
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _render_summary(summary: Sequence[VerificationMessage], console: Console) -> None:
|
|
463
|
+
"""Print each headline verdict beside what it is a verdict about.
|
|
464
|
+
|
|
465
|
+
This is not the labelled-facts table it resembles. The left column holds
|
|
466
|
+
the outcome rather than the name of a value, and an outcome is the thing a
|
|
467
|
+
reader is looking for, not the word that says which value follows. So it
|
|
468
|
+
keeps its own renderer and carries the same colours Doctor gives its own
|
|
469
|
+
checks, because a reader who has seen one of them should be able to read
|
|
470
|
+
the other at a glance.
|
|
471
|
+
"""
|
|
472
|
+
table = Table(box=None, show_header=False, pad_edge=False, padding=(0, 2))
|
|
473
|
+
table.add_column("status", no_wrap=True)
|
|
474
|
+
table.add_column("detail", overflow="fold")
|
|
475
|
+
for message in summary:
|
|
476
|
+
table.add_row(
|
|
477
|
+
Text(message.status.upper(), style=_STATUS_STYLE[message.status]),
|
|
478
|
+
message.detail,
|
|
479
|
+
)
|
|
480
|
+
console.print(table)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _render_counts(
|
|
484
|
+
headings: Sequence[tuple[str, list[VerificationMessage]]],
|
|
485
|
+
total: int,
|
|
486
|
+
console: Console,
|
|
487
|
+
) -> None:
|
|
488
|
+
console.print()
|
|
489
|
+
console.print(f"What was checked, {total} checks in all")
|
|
490
|
+
rows = [(heading, *_tally(checks)) for heading, checks in headings]
|
|
491
|
+
table = Table(box=None, show_header=False, pad_edge=True, padding=(0, 2))
|
|
492
|
+
table.add_column("heading", overflow="fold")
|
|
493
|
+
table.add_column("outcome", justify="right", no_wrap=True)
|
|
494
|
+
# The third column exists only when something is in it, so a proof that
|
|
495
|
+
# holds together prints no column of blanks beside its counts.
|
|
496
|
+
troubled = any(note for _, _, note in rows)
|
|
497
|
+
if troubled:
|
|
498
|
+
table.add_column("note", no_wrap=True)
|
|
499
|
+
for heading, outcome, note in rows:
|
|
500
|
+
cells = (heading, outcome, note) if troubled else (heading, outcome)
|
|
501
|
+
table.add_row(*cells)
|
|
502
|
+
console.print(table)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _render_every_check(
|
|
506
|
+
headings: Sequence[tuple[str, list[VerificationMessage]]], console: Console
|
|
507
|
+
) -> None:
|
|
508
|
+
for heading, checks in headings:
|
|
509
|
+
console.print()
|
|
510
|
+
console.print(heading)
|
|
511
|
+
rows = [
|
|
512
|
+
(message.status.upper(), _named_beside(message), message.detail)
|
|
513
|
+
for message in checks
|
|
514
|
+
]
|
|
515
|
+
table = Table(box=None, show_header=False, pad_edge=True, padding=(0, 2))
|
|
516
|
+
table.add_column("status", no_wrap=True)
|
|
517
|
+
# Most checks say what they were about in their own words. The ones
|
|
518
|
+
# that do not are named beside them rather than left to the reader.
|
|
519
|
+
named = any(subject for _, subject, _ in rows)
|
|
520
|
+
if named:
|
|
521
|
+
table.add_column("subject", overflow="fold")
|
|
522
|
+
table.add_column("confirmed", overflow="fold")
|
|
523
|
+
for status, subject, detail in rows:
|
|
524
|
+
cells = (status, subject, detail) if named else (status, detail)
|
|
525
|
+
table.add_row(*cells)
|
|
526
|
+
console.print(table)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _render_failures(
|
|
530
|
+
failures: Sequence[VerificationMessage], total: int, console: Console
|
|
531
|
+
) -> None:
|
|
532
|
+
"""Print every failure whole: where it is, what went wrong, and its code.
|
|
533
|
+
|
|
534
|
+
Nothing here is grouped or shortened. A reader whose proof does not hold
|
|
535
|
+
together is the one reader who needs all of it, and the identifier and the
|
|
536
|
+
code are exactly the two strings they will quote to somebody else.
|
|
537
|
+
"""
|
|
538
|
+
console.print()
|
|
539
|
+
console.print(f"What failed, {len(failures)} of {total} checks")
|
|
540
|
+
table = Table(box=None, show_header=False, pad_edge=False, padding=(0, 1))
|
|
541
|
+
table.add_column("index", justify="right", no_wrap=True)
|
|
542
|
+
table.add_column("failure", overflow="fold")
|
|
543
|
+
for position, message in enumerate(failures, start=1):
|
|
544
|
+
subject = _subject_of(message)
|
|
545
|
+
where = _heading_of(message.id)
|
|
546
|
+
table.add_row(
|
|
547
|
+
f"{position}.",
|
|
548
|
+
"\n".join(
|
|
549
|
+
[
|
|
550
|
+
f"{where} — {subject}" if subject else where,
|
|
551
|
+
message.detail,
|
|
552
|
+
f"check {message.id}, reported as {message.code}",
|
|
553
|
+
]
|
|
554
|
+
),
|
|
555
|
+
)
|
|
556
|
+
console.print(table)
|