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
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Ask before a database is opened. There is nothing afterwards to record.
|
|
3
|
+
|
|
4
|
+
Governs the one attribute this pack's point names: the call this module's
|
|
5
|
+
`[[point]]` wraps opens a database and hands back a connection, and that call
|
|
6
|
+
is the whole of what this pack governs.
|
|
7
|
+
|
|
8
|
+
`database` — the database the call is asked to open, rendered as the text it
|
|
9
|
+
names whatever shape the caller passed it in — is the one argument sent to the
|
|
10
|
+
boundary. Nothing else of the call is sent (article 11: what is sent is a
|
|
11
|
+
digest, and which arguments it covers is declared, never implicit).
|
|
12
|
+
|
|
13
|
+
**As text, whatever the caller's type — the same rule `subprocess`'s pack
|
|
14
|
+
already keeps.** `sqlite3.connect` accepts a plain `str` — a file path, or the
|
|
15
|
+
special name for an in-memory database — a `bytes` path, or an
|
|
16
|
+
`os.PathLike`; all three name one database. Each is decoded to the text it
|
|
17
|
+
names with `os.fsdecode` (the interpreter's own path decoding, and the exact
|
|
18
|
+
function the sibling `subprocess` pack uses for its own `args`) before the
|
|
19
|
+
digest is taken, so `sqlite3.connect("/x/db")` and
|
|
20
|
+
`sqlite3.connect(b"/x/db")` — two spellings that open the same file — arrive
|
|
21
|
+
at the boundary under one digest. Rendering the `bytes` shape with `str()`
|
|
22
|
+
instead would send its `repr` (`"b'/x/db'"`), which is a second, unrelated
|
|
23
|
+
digest for the same database and a policy keyed on `database` could be stepped
|
|
24
|
+
around by spelling the path in bytes — exactly the defect class `subprocess`'s
|
|
25
|
+
own docstring already names for its own argument.
|
|
26
|
+
|
|
27
|
+
**Bound by position, not by `inspect.signature`.** `subprocess`'s pack and this
|
|
28
|
+
distribution's `http-client` pack both bind the call against
|
|
29
|
+
`inspect.signature(original)`, because `subprocess.Popen` and
|
|
30
|
+
`urllib.request.urlopen` both support it. The real `sqlite3.connect` does not:
|
|
31
|
+
its C implementation's own printed signature carries a default —
|
|
32
|
+
`autocommit=sqlite3.LEGACY_TRANSACTION_CONTROL` — that is not a Python literal,
|
|
33
|
+
so `inspect.signature(sqlite3.connect)` raises on the very interpreter this
|
|
34
|
+
pack ships for. That is a fact about the standard library's own C
|
|
35
|
+
implementation on this Python version, not something fixable from outside it.
|
|
36
|
+
`database` is the DB-API 2.0 shape's first parameter — the rule every driver's
|
|
37
|
+
`connect` follows, this one included — so it is read from the keyword if the
|
|
38
|
+
caller used one and from the first positional argument otherwise: exactly what
|
|
39
|
+
`inspect.signature(...).bind_partial(...)` would have read, had it been able
|
|
40
|
+
to run at all.
|
|
41
|
+
|
|
42
|
+
**No outcome digest.** A connection is not an outcome: unlike a spawned
|
|
43
|
+
process, which has a pid the moment it exists, or a finished HTTP exchange,
|
|
44
|
+
which has a status, a connection that has just been opened has nothing yet
|
|
45
|
+
worth digesting, and a failed open never reaches the line that would record
|
|
46
|
+
one — the exception propagates out of the call itself, before the ask's `with`
|
|
47
|
+
block would have anything to say. So this pack's point declares no outcome
|
|
48
|
+
digest, and none is recorded here.
|
|
49
|
+
|
|
50
|
+
The wrapper is a plain function, so what it returns is the real connection
|
|
51
|
+
object, never a stand-in for it.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import os
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def wrap(original, capability, boundary):
|
|
60
|
+
"""Ask, open, and hand back the connection. See the module docstring for
|
|
61
|
+
why `database` is read positionally rather than through `inspect.signature`."""
|
|
62
|
+
|
|
63
|
+
def wrapper(*args, **kwargs):
|
|
64
|
+
database = kwargs.get("database", args[0] if args else None)
|
|
65
|
+
arguments = {"database": _rendered(database)}
|
|
66
|
+
with boundary.request(capability, arguments):
|
|
67
|
+
return original(*args, **kwargs)
|
|
68
|
+
|
|
69
|
+
return wrapper
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _rendered(value):
|
|
73
|
+
"""`database` as the digest sees it: text, whatever the caller spelled it in.
|
|
74
|
+
|
|
75
|
+
`bytearray` is converted to `bytes` first because `os.fsdecode` does not
|
|
76
|
+
accept it directly (it takes `str`, `bytes` and `os.PathLike`), not because
|
|
77
|
+
`sqlite3.connect` is known to accept a `bytearray` database — the rendering
|
|
78
|
+
is defined for whatever `original` is actually called with, the same way
|
|
79
|
+
`subprocess`'s own `_text` is.
|
|
80
|
+
"""
|
|
81
|
+
if isinstance(value, bytearray):
|
|
82
|
+
value = bytes(value)
|
|
83
|
+
if isinstance(value, str | bytes | os.PathLike):
|
|
84
|
+
return os.fsdecode(value)
|
|
85
|
+
return str(value)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
[pack]
|
|
4
|
+
name = "database"
|
|
5
|
+
classification = "convenience"
|
|
6
|
+
classified_on = "2026-09-15"
|
|
7
|
+
|
|
8
|
+
[[point]]
|
|
9
|
+
module = "sqlite3"
|
|
10
|
+
attribute = "connect"
|
|
11
|
+
capability = "database.open"
|
|
12
|
+
digest = ["database"]
|
|
13
|
+
audit_event = "sqlite3.connect"
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
This is a convenience pack (article 9). A competent engineer would rebuild it in
|
|
3
|
+
a day from `urllib.request`'s public documentation: one attribute, one call shape,
|
|
4
|
+
one argument that names the effect. Classified on 2026-09-15.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Ask before an HTTP request leaves the process, and record what came back.
|
|
3
|
+
|
|
4
|
+
Governs `urllib.request.urlopen` — the one attribute this pack's point names.
|
|
5
|
+
It is the one function every scheme `urllib.request` handles goes through —
|
|
6
|
+
`http`, `https`, `file` and `data` alike, and every higher-level call the
|
|
7
|
+
standard library builds on top of it too — so a `file:` or `data:` read is
|
|
8
|
+
asked under this pack's capability as well, since the interpreter offers no
|
|
9
|
+
narrower seam than this one function; the URL in the digest is what
|
|
10
|
+
distinguishes them.
|
|
11
|
+
|
|
12
|
+
`url` — the address the call is asked to open, rendered as the text it names
|
|
13
|
+
whatever shape the caller passed it in — is the one argument sent to the
|
|
14
|
+
boundary. Nothing else of the call is sent (article 11: what is sent is a
|
|
15
|
+
digest, and which arguments it covers is declared, never implicit).
|
|
16
|
+
|
|
17
|
+
**As text, whatever the caller's type.** The call accepts a plain string, or a
|
|
18
|
+
`Request` object built up with headers and a method of its own; a `Request`
|
|
19
|
+
carries the address it will open under `full_url`, so that is read out rather
|
|
20
|
+
than rendering the object itself, which would give its `repr` and not the
|
|
21
|
+
address.
|
|
22
|
+
|
|
23
|
+
The wrapper is a plain function, so what it returns is the real response
|
|
24
|
+
object, never a stand-in for it.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import inspect
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def wrap(original, capability, boundary):
|
|
34
|
+
"""Bind the call to `original`'s own signature, ask, open, and record the status.
|
|
35
|
+
|
|
36
|
+
The ask carries `url` rendered as text — a `Request`'s own address, or
|
|
37
|
+
`str()` of whatever else was passed — so the digest is computable
|
|
38
|
+
whichever shape the caller called with.
|
|
39
|
+
"""
|
|
40
|
+
signature = inspect.signature(original)
|
|
41
|
+
|
|
42
|
+
def wrapper(*args, **kwargs):
|
|
43
|
+
bound = signature.bind_partial(*args, **kwargs)
|
|
44
|
+
arguments = {"url": _rendered(bound.arguments.get("url"))}
|
|
45
|
+
with boundary.request(capability, arguments) as handle:
|
|
46
|
+
response = original(*args, **kwargs)
|
|
47
|
+
status = getattr(response, "status", None)
|
|
48
|
+
if status is not None:
|
|
49
|
+
# Nothing more of the response is worth an outcome: the status
|
|
50
|
+
# is what the digest names, and nothing else of the response
|
|
51
|
+
# is sent anywhere. `hasattr` alone is not enough: a `file:`
|
|
52
|
+
# or `data:` read returns `urllib.response.addinfourl`, which
|
|
53
|
+
# HAS a `status` attribute that is always `None` — recording a
|
|
54
|
+
# digest for it would claim an outcome the response does not
|
|
55
|
+
# have (`sha256("status:None")` looks like a real status to
|
|
56
|
+
# anything reading the chain back).
|
|
57
|
+
handle.record_outcome(_status_digest(status))
|
|
58
|
+
return response
|
|
59
|
+
|
|
60
|
+
return wrapper
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _rendered(value):
|
|
64
|
+
"""`url` as the digest sees it: a `Request`'s own address, or text of whatever else."""
|
|
65
|
+
if hasattr(value, "full_url"):
|
|
66
|
+
return value.full_url
|
|
67
|
+
return str(value)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _status_digest(status):
|
|
71
|
+
"""`sha256("status:" + str(status))`, as the manifest's `audit_event` expects to see it."""
|
|
72
|
+
return hashlib.sha256(f"status:{status}".encode()).hexdigest()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
[pack]
|
|
4
|
+
name = "http-client"
|
|
5
|
+
classification = "convenience"
|
|
6
|
+
classified_on = "2026-09-15"
|
|
7
|
+
|
|
8
|
+
[[point]]
|
|
9
|
+
module = "urllib.request"
|
|
10
|
+
attribute = "urlopen"
|
|
11
|
+
capability = "net.egress"
|
|
12
|
+
digest = ["url"]
|
|
13
|
+
audit_event = "urllib.Request"
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
This is a convenience pack (article 9). A competent engineer would rebuild it in
|
|
3
|
+
a day from the `subprocess` module's public documentation: one attribute, one call shape,
|
|
4
|
+
one argument that names the effect. Classified on 2026-09-15.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Ask before a process is spawned, and record which one answered.
|
|
3
|
+
|
|
4
|
+
Governs `subprocess.Popen` — the one attribute this pack's point names. It
|
|
5
|
+
governs `subprocess.run`, `subprocess.call` and `subprocess.check_output` too,
|
|
6
|
+
without a point of their own, because all three call the module's own global
|
|
7
|
+
`Popen` to do their spawning: wrapping the name they call is wrapping them,
|
|
8
|
+
and a second point for `run` would be asking about one spawn twice.
|
|
9
|
+
|
|
10
|
+
`args` — a command line as text, or the sequence `Popen` accepts rendered as a
|
|
11
|
+
list of text; anything else (there should be none: `Popen`'s first argument is
|
|
12
|
+
required) is rendered with `str()`, so the digest `pack.toml` declares
|
|
13
|
+
(`digest = ["args"]`) is always computable — is the one argument sent to the
|
|
14
|
+
boundary. Nothing else of the call is sent (article 11: what is sent is a
|
|
15
|
+
digest, and which arguments it covers is declared, never implicit).
|
|
16
|
+
|
|
17
|
+
**As text, whatever the caller's type.** `Popen` accepts a command line as
|
|
18
|
+
`str`, as `bytes`, as a `os.PathLike`, or as a sequence of any of those, and
|
|
19
|
+
they all spawn the same process. So each is decoded to the text it names
|
|
20
|
+
(`os.fsdecode`, the interpreter's own path decoding) before the digest is
|
|
21
|
+
taken: two calls that spawn one process have one digest, and a policy keyed on
|
|
22
|
+
the command line — which is all `digest = ["args"]` gives the plane — cannot be
|
|
23
|
+
stepped around by spelling the same command in bytes.
|
|
24
|
+
|
|
25
|
+
The wrapper is a plain function, not a class, so `subprocess.run`'s own
|
|
26
|
+
`with Popen(...) as process:` still works: what this returns is the real
|
|
27
|
+
`Popen` instance, never a stand-in for it.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import inspect
|
|
34
|
+
import os
|
|
35
|
+
from collections.abc import Sequence
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def wrap(original, capability, boundary):
|
|
39
|
+
"""Bind the call to `original`'s own signature, ask, spawn, and record the pid.
|
|
40
|
+
|
|
41
|
+
The ask carries the command line as text — a `bytes` command line is
|
|
42
|
+
decoded rather than digested as bytes — so the same spawn has the same
|
|
43
|
+
digest whatever type the caller spelled it in.
|
|
44
|
+
"""
|
|
45
|
+
signature = inspect.signature(original)
|
|
46
|
+
|
|
47
|
+
def wrapper(*args, **kwargs):
|
|
48
|
+
bound = signature.bind_partial(*args, **kwargs)
|
|
49
|
+
arguments = {"args": _rendered(bound.arguments.get("args"))}
|
|
50
|
+
with boundary.request(capability, arguments) as handle:
|
|
51
|
+
process = original(*args, **kwargs)
|
|
52
|
+
if hasattr(process, "pid"):
|
|
53
|
+
# A process that has just been spawned has a pid and nothing
|
|
54
|
+
# else to report yet; that is the whole of the outcome here.
|
|
55
|
+
handle.record_outcome(_pid_digest(process.pid))
|
|
56
|
+
return process
|
|
57
|
+
|
|
58
|
+
return wrapper
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _rendered(value):
|
|
62
|
+
"""`args` as the digest sees it: text, a list of text, or `str()` of whatever else.
|
|
63
|
+
|
|
64
|
+
The one command line a caller may spell four ways — `str`, `bytes`,
|
|
65
|
+
`bytearray`, `os.PathLike` — is one command line, and `bytes` is decoded
|
|
66
|
+
before the sequence branch is even considered: it IS a sequence, of
|
|
67
|
+
integers, and rendering it as one would send the plane a list of byte codes
|
|
68
|
+
for a command a second caller sends as a string.
|
|
69
|
+
"""
|
|
70
|
+
if isinstance(value, str | bytes | bytearray | os.PathLike):
|
|
71
|
+
return _text(value)
|
|
72
|
+
if isinstance(value, Sequence):
|
|
73
|
+
return [_text(item) for item in value]
|
|
74
|
+
return str(value)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _text(value):
|
|
78
|
+
"""One command line, or one element of one, as the text it names."""
|
|
79
|
+
if isinstance(value, bytearray):
|
|
80
|
+
# `os.fsdecode` takes `str`, `bytes` and `os.PathLike`, and a
|
|
81
|
+
# `bytearray` is none of the three.
|
|
82
|
+
value = bytes(value)
|
|
83
|
+
if isinstance(value, str | bytes | os.PathLike):
|
|
84
|
+
return os.fsdecode(value)
|
|
85
|
+
return str(value)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _pid_digest(pid):
|
|
89
|
+
"""`sha256("pid:" + str(pid))`, as the manifest's `audit_event` expects to see it."""
|
|
90
|
+
return hashlib.sha256(f"pid:{pid}".encode()).hexdigest()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
[pack]
|
|
4
|
+
name = "subprocess"
|
|
5
|
+
classification = "convenience"
|
|
6
|
+
classified_on = "2026-09-15"
|
|
7
|
+
|
|
8
|
+
[[point]]
|
|
9
|
+
module = "subprocess"
|
|
10
|
+
attribute = "Popen"
|
|
11
|
+
capability = "process.spawn"
|
|
12
|
+
digest = ["args"]
|
|
13
|
+
audit_event = "subprocess.Popen"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""`sayfirst packs`: list and check the convenience packs this distribution ships.
|
|
3
|
+
|
|
4
|
+
Article 9's packs are designated by path, never by name — `instrument run
|
|
5
|
+
--pack` reads only the directory a person types, and consults no registry, no
|
|
6
|
+
default set and no name resolution of its own (`docs/PACKS.md`). This command
|
|
7
|
+
does not change that: `list` prints, for each pack this distribution ships
|
|
8
|
+
beside itself, the one path `--pack` would accept for it, so a person has
|
|
9
|
+
something to copy rather than something to guess; `check` reads a path the
|
|
10
|
+
same way the engine will, before a program is ever run with it.
|
|
11
|
+
|
|
12
|
+
Neither verb ends in a traceback. A pack that does not read is named with the
|
|
13
|
+
member and the rule it broke, and the exit code says so — for `list` as much as
|
|
14
|
+
for `check`, since the command a person uses to discover what is shipped is the
|
|
15
|
+
one that has to be able to say that something shipped is broken.
|
|
16
|
+
|
|
17
|
+
Read from the *installed* package with `importlib.resources`, never from a
|
|
18
|
+
path built off this file's own location: the packs this prints are the ones
|
|
19
|
+
this distribution actually carries, wherever it was installed.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import importlib.resources
|
|
26
|
+
import sys
|
|
27
|
+
from collections.abc import Sequence
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import TextIO
|
|
30
|
+
|
|
31
|
+
from . import exit_codes
|
|
32
|
+
from .instrument import manifest
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def shipped_packs() -> list[Path]:
|
|
36
|
+
"""Every pack directory this installed distribution carries, sorted by path.
|
|
37
|
+
|
|
38
|
+
A directory counts as a pack candidate here by carrying a manifest, not by
|
|
39
|
+
its name — `__pycache__` and anything else beside the packs is silently
|
|
40
|
+
not a pack rather than a reason this call fails; `manifest.read_pack`
|
|
41
|
+
still refuses one that names a manifest but is not, in fact, complete.
|
|
42
|
+
"""
|
|
43
|
+
root = importlib.resources.files("sayfirst_cli.packs")
|
|
44
|
+
if not root.is_dir():
|
|
45
|
+
return []
|
|
46
|
+
return sorted(
|
|
47
|
+
Path(str(item))
|
|
48
|
+
for item in root.iterdir()
|
|
49
|
+
if item.is_dir() and (item / manifest.MANIFEST_FILE).is_file()
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
54
|
+
parser = argparse.ArgumentParser(
|
|
55
|
+
prog="sayfirst packs",
|
|
56
|
+
description="List and check the convenience packs this distribution ships (article 9).",
|
|
57
|
+
)
|
|
58
|
+
verbs = parser.add_subparsers(dest="verb", required=True)
|
|
59
|
+
verbs.add_parser("list", help="one line per shipped pack: name, capabilities, path")
|
|
60
|
+
checking = verbs.add_parser("check", help="read one pack directory the way the engine will")
|
|
61
|
+
checking.add_argument("path", metavar="PATH", help="the pack directory to read")
|
|
62
|
+
return parser
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main(
|
|
66
|
+
argv: Sequence[str] | None = None, *, out: TextIO | None = None, err: TextIO | None = None
|
|
67
|
+
) -> int:
|
|
68
|
+
stdout = out or sys.stdout
|
|
69
|
+
stderr = err or sys.stderr
|
|
70
|
+
forwarded = list(sys.argv[1:] if argv is None else argv)
|
|
71
|
+
arguments = build_parser().parse_args(forwarded)
|
|
72
|
+
if arguments.verb == "list":
|
|
73
|
+
return _list(stdout, stderr)
|
|
74
|
+
return _check(Path(arguments.path), stdout, stderr)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _list(stdout: TextIO, stderr: TextIO) -> int:
|
|
78
|
+
"""One line per shipped pack: its name, every capability it declares, its path.
|
|
79
|
+
|
|
80
|
+
A pack whose manifest will not read is named on stderr with the rule it
|
|
81
|
+
broke — what `check` does with the one pack it is given — and the listing
|
|
82
|
+
goes on, so the packs after it in sort order are still printed. The exit
|
|
83
|
+
code is the misuse `check` uses, because a listing that omitted a broken
|
|
84
|
+
pack and exited 0 would render an absence as a healthy state (article 2),
|
|
85
|
+
and because a traceback out of this client is never how a pack's problem is
|
|
86
|
+
reported.
|
|
87
|
+
|
|
88
|
+
64 for a pack the *distribution* ships is the honest half of the answer
|
|
89
|
+
rather than a perfect fit — the caller typed nothing wrong, and something
|
|
90
|
+
this command named cannot be read. `exit_codes.py` states the widened
|
|
91
|
+
reading beside the number, and says why a code of its own would cost more
|
|
92
|
+
than it bought: no caller can act differently on the two.
|
|
93
|
+
"""
|
|
94
|
+
unread = 0
|
|
95
|
+
for directory in shipped_packs():
|
|
96
|
+
try:
|
|
97
|
+
pack = manifest.read_pack(directory)
|
|
98
|
+
except manifest.PackInvalid as invalid:
|
|
99
|
+
stderr.write(f"invalid {directory}: {invalid}\n")
|
|
100
|
+
unread += 1
|
|
101
|
+
continue
|
|
102
|
+
capabilities = " ".join(dict.fromkeys(point.capability for point in pack.points))
|
|
103
|
+
stdout.write(f"{pack.name} {capabilities} {directory}\n")
|
|
104
|
+
return exit_codes.EXIT_MISUSE if unread else 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _check(path: Path, stdout: TextIO, stderr: TextIO) -> int:
|
|
108
|
+
"""Read one pack, the way `instrument run` will, before anything is run with it."""
|
|
109
|
+
try:
|
|
110
|
+
pack = manifest.read_pack(path)
|
|
111
|
+
except manifest.PackInvalid as invalid:
|
|
112
|
+
stderr.write(f"{invalid}\n")
|
|
113
|
+
return exit_codes.EXIT_MISUSE
|
|
114
|
+
stdout.write(f"ok {pack.name}\n")
|
|
115
|
+
return 0
|
sayfirst_cli/pages.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""One evidence page, validated once for every command that walks one.
|
|
3
|
+
|
|
4
|
+
`trace` and `evidence` both page through scoped evidence, and each carried its
|
|
5
|
+
own copy of this. The two copies had already drifted: one refused a `next_from`
|
|
6
|
+
that did not advance and the other accepted it, one checked an effect entry's
|
|
7
|
+
`decision_id` and the other did not — so one broken daemon reply got two
|
|
8
|
+
different answers from two commands of the same commit. The union of both sets
|
|
9
|
+
of checks lives here, and both commands now ask the same question of the same
|
|
10
|
+
shape.
|
|
11
|
+
|
|
12
|
+
Only what the commands themselves consume before they claim a match, a position
|
|
13
|
+
or an absence is checked. The chain is verified by the contract alone; nothing
|
|
14
|
+
here is a second verifier.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
|
|
21
|
+
from . import reads
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UnreadablePage(ValueError):
|
|
25
|
+
"""An evidence page lacks a member the command that walks it consumes."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _checked[T](value: object, expected: type[T], member: str) -> T:
|
|
29
|
+
if not isinstance(value, expected) or (
|
|
30
|
+
expected is int and (isinstance(value, bool) or value < 1)
|
|
31
|
+
):
|
|
32
|
+
raise UnreadablePage(f"evidence page member {member} is missing or invalid")
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def members(
|
|
37
|
+
page: Mapping[str, object], from_sequence: int
|
|
38
|
+
) -> tuple[list[Mapping[str, object]], Mapping[str, object], int | None]:
|
|
39
|
+
"""The page's entries, its served verdict and its continuation.
|
|
40
|
+
|
|
41
|
+
`next_from` has to be there and it has to advance. A page that answers a
|
|
42
|
+
read from sequence 5 with `next_from: 5` is not a continuation, and walking
|
|
43
|
+
it again until a page budget runs out ends in « not found » — an absence
|
|
44
|
+
stated as a fact the read never established (article 2).
|
|
45
|
+
"""
|
|
46
|
+
if reads.too_deep(page, reads.PAGE_DEPTH_LIMIT):
|
|
47
|
+
# One page, bounded two levels shallower than a whole answer, because a
|
|
48
|
+
# page is carried inside one: `reads.PAGE_DEPTH_LIMIT` says why, and the
|
|
49
|
+
# subtraction lives there so this file cannot hold a second copy of it.
|
|
50
|
+
raise UnreadablePage(
|
|
51
|
+
f"evidence page nests deeper than {reads.PAGE_DEPTH_LIMIT} levels; "
|
|
52
|
+
"not a page this client reads"
|
|
53
|
+
)
|
|
54
|
+
entries = _checked(page.get("entries"), list, "entries")
|
|
55
|
+
for index, value in enumerate(entries):
|
|
56
|
+
prefix = f"entries[{index}]"
|
|
57
|
+
entry = _checked(value, Mapping, prefix)
|
|
58
|
+
_checked(entry.get("sequence"), int, f"{prefix}.sequence")
|
|
59
|
+
for name in ("kind", "connection_id", "entry_hash"):
|
|
60
|
+
_checked(entry.get(name), str, f"{prefix}.{name}")
|
|
61
|
+
body = _checked(entry.get("body"), Mapping, f"{prefix}.body")
|
|
62
|
+
if entry["kind"] == "effect":
|
|
63
|
+
# The member `trace` reads to decide whether an entry is the one it
|
|
64
|
+
# was asked about: a page that lacks it cannot answer the question.
|
|
65
|
+
_checked(body.get("decision_id"), str, f"{prefix}.body.decision_id")
|
|
66
|
+
served = _checked(page.get("verification"), Mapping, "verification")
|
|
67
|
+
for name, fields in (
|
|
68
|
+
("grades", (("connection_id", str), ("grade", str))),
|
|
69
|
+
("declared_gaps", (("sequence", int), ("reason", str), ("count", int))),
|
|
70
|
+
):
|
|
71
|
+
items = _checked(served.get(name), list, f"verification.{name}")
|
|
72
|
+
for index, value in enumerate(items):
|
|
73
|
+
prefix = f"verification.{name}[{index}]"
|
|
74
|
+
item = _checked(value, Mapping, prefix)
|
|
75
|
+
for field, expected in fields:
|
|
76
|
+
_checked(item.get(field), expected, f"{prefix}.{field}")
|
|
77
|
+
_checked(served.get("condition"), str, "verification.condition")
|
|
78
|
+
if served.get("sequence") is not None:
|
|
79
|
+
_checked(served["sequence"], int, "verification.sequence")
|
|
80
|
+
if "next_from" not in page:
|
|
81
|
+
raise UnreadablePage("evidence page member next_from is missing")
|
|
82
|
+
next_from = page["next_from"]
|
|
83
|
+
if next_from is not None:
|
|
84
|
+
_checked(next_from, int, "next_from")
|
|
85
|
+
if next_from <= from_sequence:
|
|
86
|
+
raise UnreadablePage("evidence page member next_from does not advance the read")
|
|
87
|
+
return entries, served, next_from
|