aevum-cli 0.7.1__tar.gz → 0.7.3__tar.gz
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.
- {aevum_cli-0.7.1 → aevum_cli-0.7.3}/PKG-INFO +3 -3
- {aevum_cli-0.7.1 → aevum_cli-0.7.3}/pyproject.toml +3 -3
- {aevum_cli-0.7.1 → aevum_cli-0.7.3}/src/aevum/cli/__init__.py +1 -1
- {aevum_cli-0.7.1 → aevum_cli-0.7.3}/src/aevum/cli/app.py +237 -4
- {aevum_cli-0.7.1 → aevum_cli-0.7.3}/tests/test_phase8_cli.py +209 -2
- {aevum_cli-0.7.1 → aevum_cli-0.7.3}/.gitignore +0 -0
- {aevum_cli-0.7.1 → aevum_cli-0.7.3}/README.md +0 -0
- {aevum_cli-0.7.1 → aevum_cli-0.7.3}/src/aevum/cli/__main__.py +0 -0
- {aevum_cli-0.7.1 → aevum_cli-0.7.3}/src/aevum/cli/commands/__init__.py +0 -0
- {aevum_cli-0.7.1 → aevum_cli-0.7.3}/src/aevum/cli/commands/complication.py +0 -0
- {aevum_cli-0.7.1 → aevum_cli-0.7.3}/src/aevum/cli/commands/conformance.py +0 -0
- {aevum_cli-0.7.1 → aevum_cli-0.7.3}/src/aevum/cli/commands/server.py +0 -0
- {aevum_cli-0.7.1 → aevum_cli-0.7.3}/src/aevum/cli/commands/store.py +0 -0
- {aevum_cli-0.7.1 → aevum_cli-0.7.3}/src/aevum/cli/commands/version.py +0 -0
- {aevum_cli-0.7.1 → aevum_cli-0.7.3}/src/aevum/cli/py.typed +0 -0
- {aevum_cli-0.7.1 → aevum_cli-0.7.3}/tests/test_cli.py +0 -0
- {aevum_cli-0.7.1 → aevum_cli-0.7.3}/tests/test_verify_receipt.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aevum-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.3
|
|
4
4
|
Summary: Aevum -- command-line interface for operating Aevum nodes.
|
|
5
5
|
Project-URL: Homepage, https://aevum.build
|
|
6
6
|
Project-URL: Repository, https://github.com/aevum-labs/aevum
|
|
7
7
|
License: Apache-2.0
|
|
8
|
-
Classifier: Development Status ::
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
9
|
Classifier: Intended Audience :: Developers
|
|
10
10
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.11
|
|
@@ -13,7 +13,7 @@ Classifier: Typing :: Typed
|
|
|
13
13
|
Requires-Python: >=3.11
|
|
14
14
|
Requires-Dist: aevum-core
|
|
15
15
|
Requires-Dist: aevum-server
|
|
16
|
-
Requires-Dist: typer
|
|
16
|
+
Requires-Dist: typer>=0.12
|
|
17
17
|
Requires-Dist: uvicorn[standard]>=0.30
|
|
18
18
|
Provides-Extra: conform
|
|
19
19
|
Requires-Dist: aevum-conformance; extra == 'conform'
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "aevum-cli"
|
|
3
|
-
version = "0.7.
|
|
3
|
+
version = "0.7.3"
|
|
4
4
|
description = "Aevum -- command-line interface for operating Aevum nodes."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
7
7
|
license = { text = "Apache-2.0" }
|
|
8
8
|
classifiers = [
|
|
9
|
-
"Development Status ::
|
|
9
|
+
"Development Status :: 4 - Beta",
|
|
10
10
|
"Intended Audience :: Developers",
|
|
11
11
|
"License :: OSI Approved :: Apache Software License",
|
|
12
12
|
"Programming Language :: Python :: 3.11",
|
|
@@ -15,7 +15,7 @@ classifiers = [
|
|
|
15
15
|
dependencies = [
|
|
16
16
|
"aevum-core",
|
|
17
17
|
"aevum-server",
|
|
18
|
-
"typer
|
|
18
|
+
"typer>=0.12",
|
|
19
19
|
"uvicorn[standard]>=0.30",
|
|
20
20
|
]
|
|
21
21
|
|
|
@@ -87,18 +87,42 @@ def init(
|
|
|
87
87
|
|
|
88
88
|
@app.command()
|
|
89
89
|
def verify(
|
|
90
|
-
|
|
90
|
+
receipt_or_session: Annotated[
|
|
91
|
+
str,
|
|
92
|
+
typer.Argument(
|
|
93
|
+
help=(
|
|
94
|
+
"Session ID to verify (queries local DB), "
|
|
95
|
+
"or path to a receipt JSON file (no DB required)."
|
|
96
|
+
)
|
|
97
|
+
),
|
|
98
|
+
],
|
|
91
99
|
state_dir: Annotated[
|
|
92
100
|
Path,
|
|
93
101
|
typer.Option("--state-dir", "-s"),
|
|
94
102
|
] = _DEFAULT_STATE,
|
|
95
103
|
) -> None:
|
|
96
104
|
"""
|
|
97
|
-
Verify a session
|
|
105
|
+
Verify a session or receipt file.
|
|
106
|
+
|
|
107
|
+
Session mode: aevum verify <session_id>
|
|
108
|
+
Re-reads stored events, recomputes Merkle root. Requires local DB.
|
|
109
|
+
|
|
110
|
+
Receipt mode: aevum verify receipt.json
|
|
111
|
+
Reads a self-contained receipt file (produced by `aevum receipt`).
|
|
112
|
+
Verifies hash chain without accessing a local DB.
|
|
98
113
|
|
|
99
|
-
|
|
100
|
-
Merkle root, and compares it to the signed root in the sigchain.
|
|
114
|
+
Exit 0 = VERIFIED. Exit 1 = TAMPERED or not found.
|
|
101
115
|
"""
|
|
116
|
+
receipt_path = Path(receipt_or_session)
|
|
117
|
+
if receipt_path.exists() and receipt_path.suffix in (".cbor", ".cose"):
|
|
118
|
+
_verify_cose_receipt(receipt_path)
|
|
119
|
+
elif receipt_path.exists() and receipt_path.suffix == ".json":
|
|
120
|
+
_verify_receipt_file(receipt_path)
|
|
121
|
+
else:
|
|
122
|
+
_verify_session(receipt_or_session, state_dir)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _verify_session(session_id: str, state_dir: Path) -> None:
|
|
102
126
|
db_path = state_dir / "aevum.db"
|
|
103
127
|
if not db_path.exists():
|
|
104
128
|
typer.echo(f"Database not found: {db_path}", err=True)
|
|
@@ -131,6 +155,215 @@ def verify(
|
|
|
131
155
|
raise typer.Exit(code=1) from None
|
|
132
156
|
|
|
133
157
|
|
|
158
|
+
def _compute_merkle_from_entries(entries: list[dict[str, Any]]) -> str:
|
|
159
|
+
"""Replicates SessionRecord.compute_merkle_root() over receipt entry dicts."""
|
|
160
|
+
if not entries:
|
|
161
|
+
return hashlib.sha256(b"").hexdigest()
|
|
162
|
+
sorted_entries = sorted(entries, key=lambda e: e["sequence"])
|
|
163
|
+
leaves = [
|
|
164
|
+
hashlib.sha256((e["input_hash"] + e["output_hash"]).encode("ascii")).hexdigest()
|
|
165
|
+
for e in sorted_entries
|
|
166
|
+
]
|
|
167
|
+
current = leaves
|
|
168
|
+
while len(current) > 1:
|
|
169
|
+
next_level: list[str] = []
|
|
170
|
+
for i in range(0, len(current), 2):
|
|
171
|
+
left = current[i]
|
|
172
|
+
right = current[i + 1] if i + 1 < len(current) else left
|
|
173
|
+
next_level.append(hashlib.sha256((left + right).encode("ascii")).hexdigest())
|
|
174
|
+
current = next_level
|
|
175
|
+
return current[0]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _verify_cose_receipt(receipt_path: Path) -> None:
|
|
179
|
+
"""Verify a COSE_Sign1 receipt file (.cbor / .cose) via the verify command."""
|
|
180
|
+
try:
|
|
181
|
+
import cbor2
|
|
182
|
+
import nacl.exceptions
|
|
183
|
+
import nacl.signing
|
|
184
|
+
from aevum.core.receipt import AevumReceipt
|
|
185
|
+
except ImportError as exc:
|
|
186
|
+
typer.echo(f"Missing dependency: {exc}", err=True)
|
|
187
|
+
raise typer.Exit(code=1) from None
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
raw = receipt_path.read_bytes()
|
|
191
|
+
cose = cbor2.loads(raw)
|
|
192
|
+
except Exception as exc: # noqa: BLE001
|
|
193
|
+
typer.echo(f"INVALID: cannot read or decode {receipt_path}: {exc}", err=True)
|
|
194
|
+
raise typer.Exit(code=1) from None
|
|
195
|
+
|
|
196
|
+
if not isinstance(cose, list) or len(cose) != 4:
|
|
197
|
+
typer.echo("INVALID: not a 4-element COSE_Sign1 array", err=True)
|
|
198
|
+
raise typer.Exit(code=1)
|
|
199
|
+
|
|
200
|
+
protected_bstr, unprotected, payload_bstr, signature_bytes = cose
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
protected = cbor2.loads(protected_bstr)
|
|
204
|
+
except Exception as exc: # noqa: BLE001
|
|
205
|
+
typer.echo(f"INVALID: cannot decode protected header: {exc}", err=True)
|
|
206
|
+
raise typer.Exit(code=1) from None
|
|
207
|
+
|
|
208
|
+
alg = protected.get(1)
|
|
209
|
+
if alg != -8:
|
|
210
|
+
typer.echo(
|
|
211
|
+
f"UNSUPPORTED ALGORITHM: alg={alg!r} (expected -8 for EdDSA/Ed25519)", err=True
|
|
212
|
+
)
|
|
213
|
+
raise typer.Exit(code=2)
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
receipt = AevumReceipt.model_validate(cbor2.loads(payload_bstr))
|
|
217
|
+
except Exception as exc: # noqa: BLE001
|
|
218
|
+
typer.echo(f"INVALID: cannot decode receipt payload: {exc}", err=True)
|
|
219
|
+
raise typer.Exit(code=1) from None
|
|
220
|
+
|
|
221
|
+
sig_structure = cbor2.dumps(["Signature1", protected_bstr, b"", payload_bstr])
|
|
222
|
+
digest = hashlib.sha3_256(sig_structure).digest()
|
|
223
|
+
|
|
224
|
+
pub_key_bytes: bytes | None = None
|
|
225
|
+
ed25519_pub_path = _DEFAULT_STATE / "ed25519.pub"
|
|
226
|
+
if ed25519_pub_path.exists():
|
|
227
|
+
pub_key_bytes = ed25519_pub_path.read_bytes()
|
|
228
|
+
|
|
229
|
+
if pub_key_bytes is None:
|
|
230
|
+
typer.echo(
|
|
231
|
+
typer.style("STRUCTURE VALID (signature not checked)", fg=typer.colors.YELLOW)
|
|
232
|
+
)
|
|
233
|
+
typer.echo(" Set ~/.aevum/ed25519.pub to verify the Ed25519 signature.")
|
|
234
|
+
typer.echo(f" Action: {receipt.action}")
|
|
235
|
+
typer.echo(f" Principal: {receipt.principal}")
|
|
236
|
+
typer.echo(f" At: {receipt.occurred_at}")
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
verify_key = nacl.signing.VerifyKey(pub_key_bytes)
|
|
241
|
+
verify_key.verify(digest, bytes(signature_bytes))
|
|
242
|
+
except nacl.exceptions.BadSignatureError:
|
|
243
|
+
typer.echo(typer.style("TAMPERED", fg=typer.colors.RED), err=True)
|
|
244
|
+
raise typer.Exit(code=1) from None
|
|
245
|
+
except Exception as exc: # noqa: BLE001
|
|
246
|
+
typer.echo(f"SIGNATURE INVALID: {exc}", err=True)
|
|
247
|
+
raise typer.Exit(code=1) from None
|
|
248
|
+
|
|
249
|
+
typer.echo(
|
|
250
|
+
typer.style(
|
|
251
|
+
f"Session {receipt.agent_id[:12]}... VERIFIED", fg=typer.colors.GREEN
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
typer.echo(f" Action: {receipt.action}")
|
|
255
|
+
typer.echo(f" Principal: {receipt.principal}")
|
|
256
|
+
typer.echo(f" At: {receipt.occurred_at}")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _verify_receipt_file(receipt_path: Path) -> None:
|
|
260
|
+
"""Verify a receipt JSON file without database access."""
|
|
261
|
+
try:
|
|
262
|
+
receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
|
|
263
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
264
|
+
typer.echo(f"Cannot read receipt file: {exc}", err=True)
|
|
265
|
+
raise typer.Exit(code=1) from None
|
|
266
|
+
|
|
267
|
+
required = {"session_id", "entry_count", "entries", "exported_at", "merkle_root"}
|
|
268
|
+
missing = required - set(receipt.keys())
|
|
269
|
+
if missing:
|
|
270
|
+
typer.echo(f"Invalid receipt: missing fields {sorted(missing)}", err=True)
|
|
271
|
+
raise typer.Exit(code=1)
|
|
272
|
+
|
|
273
|
+
session_id = receipt["session_id"]
|
|
274
|
+
entries = receipt["entries"]
|
|
275
|
+
entry_count = receipt["entry_count"]
|
|
276
|
+
stored_root = receipt["merkle_root"]
|
|
277
|
+
|
|
278
|
+
if len(entries) != entry_count:
|
|
279
|
+
typer.echo(
|
|
280
|
+
typer.style(f"Session {session_id[:12]}... TAMPERED", fg=typer.colors.RED),
|
|
281
|
+
err=True,
|
|
282
|
+
)
|
|
283
|
+
typer.echo(
|
|
284
|
+
f" Entry count mismatch: expected {entry_count}, found {len(entries)}",
|
|
285
|
+
err=True,
|
|
286
|
+
)
|
|
287
|
+
raise typer.Exit(code=1)
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
recomputed_root = _compute_merkle_from_entries(entries)
|
|
291
|
+
except (KeyError, TypeError) as exc:
|
|
292
|
+
typer.echo(f"Invalid receipt entries: {exc}", err=True)
|
|
293
|
+
raise typer.Exit(code=1) from None
|
|
294
|
+
|
|
295
|
+
if recomputed_root != stored_root:
|
|
296
|
+
typer.echo(
|
|
297
|
+
typer.style(f"Session {session_id[:12]}... TAMPERED", fg=typer.colors.RED),
|
|
298
|
+
err=True,
|
|
299
|
+
)
|
|
300
|
+
typer.echo(" Merkle root mismatch:", err=True)
|
|
301
|
+
typer.echo(f" Stored: {stored_root[:16]}...", err=True)
|
|
302
|
+
typer.echo(f" Recomputed: {recomputed_root[:16]}...", err=True)
|
|
303
|
+
raise typer.Exit(code=1)
|
|
304
|
+
|
|
305
|
+
typer.echo(typer.style(f"Session {session_id[:12]}... VERIFIED", fg=typer.colors.GREEN))
|
|
306
|
+
typer.echo(f" Merkle root: {recomputed_root[:16]}...")
|
|
307
|
+
typer.echo(f" Entries: {len(entries)}")
|
|
308
|
+
typer.echo(f" Exported at: {receipt.get('exported_at', 'unknown')}")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@app.command()
|
|
312
|
+
def receipt(
|
|
313
|
+
session_id: Annotated[str, typer.Argument(help="Session ID to export as a receipt")],
|
|
314
|
+
state_dir: Annotated[
|
|
315
|
+
Path,
|
|
316
|
+
typer.Option("--state-dir", "-s"),
|
|
317
|
+
] = _DEFAULT_STATE,
|
|
318
|
+
) -> None:
|
|
319
|
+
"""
|
|
320
|
+
Export a self-contained JSON receipt for a session.
|
|
321
|
+
|
|
322
|
+
Prints JSON to stdout — redirect to save:
|
|
323
|
+
aevum receipt <session_id> > proof.json
|
|
324
|
+
|
|
325
|
+
Verify the receipt offline (no DB required):
|
|
326
|
+
aevum verify proof.json
|
|
327
|
+
"""
|
|
328
|
+
import datetime as dt
|
|
329
|
+
|
|
330
|
+
db_path = state_dir / "aevum.db"
|
|
331
|
+
if not db_path.exists():
|
|
332
|
+
typer.echo(f"Database not found: {db_path}", err=True)
|
|
333
|
+
raise typer.Exit(code=1)
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
from aevum.core.replay import ReplayEngine
|
|
337
|
+
engine = ReplayEngine(db_path)
|
|
338
|
+
record = engine.load_session_record(session_id)
|
|
339
|
+
|
|
340
|
+
entries = [
|
|
341
|
+
{
|
|
342
|
+
"sequence": ev.sequence,
|
|
343
|
+
"event_type": ev.event_type.value,
|
|
344
|
+
"input_hash": ev.input_hash,
|
|
345
|
+
"output_hash": ev.output_hash,
|
|
346
|
+
"occurred_at": ev.occurred_at.isoformat(),
|
|
347
|
+
"latency_ms": ev.latency_ms,
|
|
348
|
+
}
|
|
349
|
+
for ev in record.events
|
|
350
|
+
]
|
|
351
|
+
|
|
352
|
+
receipt_doc = {
|
|
353
|
+
"session_id": record.session_id,
|
|
354
|
+
"exported_at": dt.datetime.now(dt.UTC).isoformat(),
|
|
355
|
+
"entry_count": len(entries),
|
|
356
|
+
"merkle_root": record.merkle_root,
|
|
357
|
+
"entries": entries,
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
typer.echo(json.dumps(receipt_doc, indent=2))
|
|
361
|
+
|
|
362
|
+
except ValueError as exc:
|
|
363
|
+
typer.echo(f"Session not found: {exc}", err=True)
|
|
364
|
+
raise typer.Exit(code=1) from None
|
|
365
|
+
|
|
366
|
+
|
|
134
367
|
@app.command(name="audit-pack")
|
|
135
368
|
def audit_pack(
|
|
136
369
|
session_id: Annotated[str, typer.Argument(help="Session ID")],
|
|
@@ -321,7 +321,7 @@ class TestCLIHelp:
|
|
|
321
321
|
result = runner.invoke(app, ["--help"])
|
|
322
322
|
assert result.exit_code == 0
|
|
323
323
|
clean = strip_ansi(result.output)
|
|
324
|
-
for cmd in ("init", "verify", "audit-pack", "conform", "replay"):
|
|
324
|
+
for cmd in ("init", "verify", "receipt", "audit-pack", "conform", "replay"):
|
|
325
325
|
assert cmd in clean
|
|
326
326
|
|
|
327
327
|
def test_help_still_shows_existing_commands(self) -> None:
|
|
@@ -332,7 +332,7 @@ class TestCLIHelp:
|
|
|
332
332
|
assert cmd in clean
|
|
333
333
|
|
|
334
334
|
def test_each_new_command_has_help(self) -> None:
|
|
335
|
-
for cmd in ("init", "verify", "audit-pack", "conform", "replay"):
|
|
335
|
+
for cmd in ("init", "verify", "receipt", "audit-pack", "conform", "replay"):
|
|
336
336
|
result = runner.invoke(app, [cmd, "--help"])
|
|
337
337
|
assert result.exit_code == 0, f"--help failed for command: {cmd}"
|
|
338
338
|
|
|
@@ -350,3 +350,210 @@ class TestCLIHelp:
|
|
|
350
350
|
result = runner.invoke(app, ["replay", "--help"])
|
|
351
351
|
assert result.exit_code == 0
|
|
352
352
|
assert "verbose" in strip_ansi(result.output).lower()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
_EMPTY_MERKLE = hashlib.sha256(b"").hexdigest()
|
|
356
|
+
|
|
357
|
+
_DB_SCHEMA = """
|
|
358
|
+
CREATE TABLE sessions (
|
|
359
|
+
session_id TEXT PRIMARY KEY, commit_type TEXT,
|
|
360
|
+
principal TEXT, purpose TEXT, started_at TEXT,
|
|
361
|
+
closed_at TEXT, event_count INTEGER, fact_count INTEGER,
|
|
362
|
+
checkpoint_count INTEGER, merkle_root TEXT,
|
|
363
|
+
ed25519_sig TEXT, mldsa65_sig TEXT, ed25519_pub TEXT,
|
|
364
|
+
mldsa65_pub TEXT, tsa_token TEXT, sigchain_entry_id INTEGER
|
|
365
|
+
);
|
|
366
|
+
CREATE TABLE session_events (
|
|
367
|
+
event_id TEXT PRIMARY KEY, session_id TEXT,
|
|
368
|
+
sequence INTEGER, event_type TEXT, occurred_at TEXT,
|
|
369
|
+
input_hash TEXT, output_hash TEXT, latency_ms INTEGER
|
|
370
|
+
);
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _receipt_merkle_root(entries: list[dict]) -> str: # type: ignore[type-arg]
|
|
375
|
+
"""Compute the same Merkle root as _compute_merkle_from_entries for test fixtures."""
|
|
376
|
+
if not entries:
|
|
377
|
+
return hashlib.sha256(b"").hexdigest()
|
|
378
|
+
sorted_entries = sorted(entries, key=lambda e: e["sequence"])
|
|
379
|
+
leaves = [
|
|
380
|
+
hashlib.sha256((e["input_hash"] + e["output_hash"]).encode("ascii")).hexdigest()
|
|
381
|
+
for e in sorted_entries
|
|
382
|
+
]
|
|
383
|
+
current = leaves
|
|
384
|
+
while len(current) > 1:
|
|
385
|
+
nxt = []
|
|
386
|
+
for i in range(0, len(current), 2):
|
|
387
|
+
left = current[i]
|
|
388
|
+
right = current[i + 1] if i + 1 < len(current) else left
|
|
389
|
+
nxt.append(hashlib.sha256((left + right).encode("ascii")).hexdigest())
|
|
390
|
+
current = nxt
|
|
391
|
+
return current[0]
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _make_receipt_doc(
|
|
395
|
+
session_id: str = "test-session-001",
|
|
396
|
+
n_entries: int = 0,
|
|
397
|
+
tamper_count: bool = False,
|
|
398
|
+
tamper_root: bool = False,
|
|
399
|
+
) -> dict: # type: ignore[type-arg]
|
|
400
|
+
entries = []
|
|
401
|
+
for i in range(n_entries):
|
|
402
|
+
entries.append({
|
|
403
|
+
"sequence": i,
|
|
404
|
+
"event_type": "relate",
|
|
405
|
+
"input_hash": hashlib.sha256(f"in-{i}".encode()).hexdigest(),
|
|
406
|
+
"output_hash": hashlib.sha256(f"out-{i}".encode()).hexdigest(),
|
|
407
|
+
"occurred_at": "2026-01-01T00:00:00+00:00",
|
|
408
|
+
"latency_ms": 5,
|
|
409
|
+
})
|
|
410
|
+
return {
|
|
411
|
+
"session_id": session_id,
|
|
412
|
+
"exported_at": "2026-01-01T01:00:00+00:00",
|
|
413
|
+
"entry_count": len(entries) + (1 if tamper_count else 0),
|
|
414
|
+
"merkle_root": ("a" * 64) if tamper_root else _receipt_merkle_root(entries),
|
|
415
|
+
"entries": entries,
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class TestReceiptCommand:
|
|
420
|
+
def _make_db(self, tmp_path: Path, session_id: str = "sess-r01") -> None:
|
|
421
|
+
db = tmp_path / "aevum.db"
|
|
422
|
+
conn = sqlite3.connect(str(db))
|
|
423
|
+
conn.executescript(_DB_SCHEMA)
|
|
424
|
+
now = datetime.now(UTC).isoformat()
|
|
425
|
+
conn.execute(
|
|
426
|
+
"INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?,?,?,NULL,NULL,NULL,NULL,NULL,NULL)",
|
|
427
|
+
(session_id, "complete", "alice", "test", now, now, 0, 0, 0, _EMPTY_MERKLE),
|
|
428
|
+
)
|
|
429
|
+
conn.commit()
|
|
430
|
+
conn.close()
|
|
431
|
+
|
|
432
|
+
def test_receipt_help(self) -> None:
|
|
433
|
+
result = runner.invoke(app, ["receipt", "--help"])
|
|
434
|
+
assert result.exit_code == 0
|
|
435
|
+
assert "receipt" in strip_ansi(result.output).lower()
|
|
436
|
+
|
|
437
|
+
def test_receipt_missing_db_exits_1(self, tmp_path: Path) -> None:
|
|
438
|
+
result = runner.invoke(
|
|
439
|
+
app, ["receipt", "sess-r01", "--state-dir", str(tmp_path)]
|
|
440
|
+
)
|
|
441
|
+
assert result.exit_code == 1
|
|
442
|
+
assert "not found" in strip_ansi(result.output).lower()
|
|
443
|
+
|
|
444
|
+
def test_receipt_valid_session_outputs_json(self, tmp_path: Path) -> None:
|
|
445
|
+
self._make_db(tmp_path)
|
|
446
|
+
result = runner.invoke(
|
|
447
|
+
app, ["receipt", "sess-r01", "--state-dir", str(tmp_path)]
|
|
448
|
+
)
|
|
449
|
+
assert result.exit_code == 0
|
|
450
|
+
doc = json.loads(strip_ansi(result.output))
|
|
451
|
+
assert doc["session_id"] == "sess-r01"
|
|
452
|
+
|
|
453
|
+
def test_receipt_output_has_required_fields(self, tmp_path: Path) -> None:
|
|
454
|
+
self._make_db(tmp_path)
|
|
455
|
+
result = runner.invoke(
|
|
456
|
+
app, ["receipt", "sess-r01", "--state-dir", str(tmp_path)]
|
|
457
|
+
)
|
|
458
|
+
assert result.exit_code == 0
|
|
459
|
+
doc = json.loads(strip_ansi(result.output))
|
|
460
|
+
for field in ("session_id", "entry_count", "entries", "exported_at", "merkle_root"):
|
|
461
|
+
assert field in doc, f"Missing field: {field}"
|
|
462
|
+
|
|
463
|
+
def test_receipt_empty_session_has_correct_merkle_root(self, tmp_path: Path) -> None:
|
|
464
|
+
self._make_db(tmp_path)
|
|
465
|
+
result = runner.invoke(
|
|
466
|
+
app, ["receipt", "sess-r01", "--state-dir", str(tmp_path)]
|
|
467
|
+
)
|
|
468
|
+
assert result.exit_code == 0
|
|
469
|
+
doc = json.loads(strip_ansi(result.output))
|
|
470
|
+
assert doc["entry_count"] == 0
|
|
471
|
+
assert doc["merkle_root"] == _EMPTY_MERKLE
|
|
472
|
+
|
|
473
|
+
def test_receipt_missing_session_exits_1(self, tmp_path: Path) -> None:
|
|
474
|
+
self._make_db(tmp_path)
|
|
475
|
+
result = runner.invoke(
|
|
476
|
+
app, ["receipt", "no-such-session", "--state-dir", str(tmp_path)]
|
|
477
|
+
)
|
|
478
|
+
assert result.exit_code == 1
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
class TestVerifyReceiptFile:
|
|
482
|
+
def _write_receipt(self, tmp_path: Path, doc: dict) -> Path: # type: ignore[type-arg]
|
|
483
|
+
f = tmp_path / "receipt.json"
|
|
484
|
+
f.write_text(json.dumps(doc), encoding="utf-8")
|
|
485
|
+
return f
|
|
486
|
+
|
|
487
|
+
def test_verify_receipt_file_valid_exits_0(self, tmp_path: Path) -> None:
|
|
488
|
+
f = self._write_receipt(tmp_path, _make_receipt_doc())
|
|
489
|
+
result = runner.invoke(app, ["verify", str(f)])
|
|
490
|
+
assert result.exit_code == 0
|
|
491
|
+
assert "VERIFIED" in strip_ansi(result.output)
|
|
492
|
+
|
|
493
|
+
def test_verify_receipt_file_with_entries_exits_0(self, tmp_path: Path) -> None:
|
|
494
|
+
f = self._write_receipt(tmp_path, _make_receipt_doc(n_entries=2))
|
|
495
|
+
result = runner.invoke(app, ["verify", str(f)])
|
|
496
|
+
assert result.exit_code == 0
|
|
497
|
+
assert "VERIFIED" in strip_ansi(result.output)
|
|
498
|
+
|
|
499
|
+
def test_verify_receipt_file_shows_merkle_root(self, tmp_path: Path) -> None:
|
|
500
|
+
f = self._write_receipt(tmp_path, _make_receipt_doc(n_entries=2))
|
|
501
|
+
result = runner.invoke(app, ["verify", str(f)])
|
|
502
|
+
assert result.exit_code == 0
|
|
503
|
+
assert "Merkle root:" in strip_ansi(result.output)
|
|
504
|
+
|
|
505
|
+
def test_verify_receipt_file_tampered_count_exits_1(self, tmp_path: Path) -> None:
|
|
506
|
+
f = self._write_receipt(tmp_path, _make_receipt_doc(n_entries=2, tamper_count=True))
|
|
507
|
+
result = runner.invoke(app, ["verify", str(f)])
|
|
508
|
+
assert result.exit_code == 1
|
|
509
|
+
assert "TAMPERED" in strip_ansi(result.output)
|
|
510
|
+
|
|
511
|
+
def test_verify_receipt_file_tampered_root_exits_1(self, tmp_path: Path) -> None:
|
|
512
|
+
f = self._write_receipt(tmp_path, _make_receipt_doc(n_entries=2, tamper_root=True))
|
|
513
|
+
result = runner.invoke(app, ["verify", str(f)])
|
|
514
|
+
assert result.exit_code == 1
|
|
515
|
+
assert "TAMPERED" in strip_ansi(result.output)
|
|
516
|
+
|
|
517
|
+
def test_verify_receipt_file_missing_fields_exits_1(self, tmp_path: Path) -> None:
|
|
518
|
+
f = tmp_path / "partial.json"
|
|
519
|
+
f.write_text('{"session_id": "x", "entries": []}', encoding="utf-8")
|
|
520
|
+
result = runner.invoke(app, ["verify", str(f)])
|
|
521
|
+
assert result.exit_code == 1
|
|
522
|
+
|
|
523
|
+
def test_verify_receipt_file_bad_json_exits_1(self, tmp_path: Path) -> None:
|
|
524
|
+
f = tmp_path / "bad.json"
|
|
525
|
+
f.write_bytes(b"not valid json {{{")
|
|
526
|
+
result = runner.invoke(app, ["verify", str(f)])
|
|
527
|
+
assert result.exit_code == 1
|
|
528
|
+
|
|
529
|
+
def test_verify_dispatches_to_session_mode_for_nonfile(self, tmp_path: Path) -> None:
|
|
530
|
+
result = runner.invoke(
|
|
531
|
+
app, ["verify", "sess-abc-not-a-file", "--state-dir", str(tmp_path)]
|
|
532
|
+
)
|
|
533
|
+
assert result.exit_code == 1
|
|
534
|
+
assert "not found" in strip_ansi(result.output).lower()
|
|
535
|
+
|
|
536
|
+
def test_verify_receipt_roundtrip(self, tmp_path: Path) -> None:
|
|
537
|
+
"""Receipt produced by aevum receipt verifies cleanly with aevum verify."""
|
|
538
|
+
db = tmp_path / "aevum.db"
|
|
539
|
+
conn = sqlite3.connect(str(db))
|
|
540
|
+
conn.executescript(_DB_SCHEMA)
|
|
541
|
+
now = datetime.now(UTC).isoformat()
|
|
542
|
+
conn.execute(
|
|
543
|
+
"INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?,?,?,NULL,NULL,NULL,NULL,NULL,NULL)",
|
|
544
|
+
("roundtrip-01", "complete", "alice", "test", now, now, 0, 0, 0, _EMPTY_MERKLE),
|
|
545
|
+
)
|
|
546
|
+
conn.commit()
|
|
547
|
+
conn.close()
|
|
548
|
+
|
|
549
|
+
export_result = runner.invoke(
|
|
550
|
+
app, ["receipt", "roundtrip-01", "--state-dir", str(tmp_path)]
|
|
551
|
+
)
|
|
552
|
+
assert export_result.exit_code == 0
|
|
553
|
+
|
|
554
|
+
receipt_file = tmp_path / "roundtrip.json"
|
|
555
|
+
receipt_file.write_text(export_result.output, encoding="utf-8")
|
|
556
|
+
|
|
557
|
+
verify_result = runner.invoke(app, ["verify", str(receipt_file)])
|
|
558
|
+
assert verify_result.exit_code == 0
|
|
559
|
+
assert "VERIFIED" in strip_ansi(verify_result.output)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|