forked from ChelseaKR/habitable
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_guards.py
More file actions
164 lines (141 loc) · 7 KB
/
Copy pathtest_guards.py
File metadata and controls
164 lines (141 loc) · 7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright 2026 Chelsea Kelly-Reif
"""Invariant guards: no custody-actor identity in exports, and a verifier that stays
within its Apache-2.0 redistributable subset.
These pin two promises the project makes elsewhere in prose: a packet proves custody
*without naming who did what* (threat model §4, README hard rules), and the
verification subset can be embedded under Apache-2.0 without dragging in AGPL-only
modules (verify.py docstring, NOTICE)."""
from __future__ import annotations
import re
import subprocess
import sys
from collections.abc import Callable
from pathlib import Path
from habitable.capture import capture
from habitable.packet import build_packet
from habitable.sync import LocalDirTransport, sync
from habitable.tsa import LocalRfc3161TSA
from habitable.vault import Vault
_GENERATED_AT = "2026-01-02T00:10:00Z"
_TENANT_FILENAME = "TENANT-PRIVATE-FILENAME-9e21.jpg"
def test_export_drops_source_filename_and_importing_peer_identity(
make_vault: Callable[..., Vault],
make_jpeg: Callable[..., Path],
local_tsa: LocalRfc3161TSA,
tmp_path: Path,
) -> None:
a = make_vault("A")
b = make_vault("B", passphrase="pw-b")
issue = a.document.add_issue(category="mold", issue_id="i1")
capture(a, make_jpeg(_TENANT_FILENAME), issue_id=issue, tsa=local_tsa)
# 1. The producer's OWN packet must not carry the tenant's source filename.
out_a = tmp_path / "packet-a"
build_packet(a, out_a, generated_at=_GENERATED_AT)
bundle_a = (out_a / "bundle.json").read_text(encoding="utf-8")
assert _TENANT_FILENAME not in bundle_a
# 2. After B imports the item from A and exports, A's fingerprint — a custody-actor
# identity — must not appear, though B's own producer fingerprint legitimately may.
transport = LocalDirTransport(tmp_path / "mbox")
sync(a, b.identity.public(), transport, channel="room")
sync(b, a.identity.public(), transport, channel="room")
out_b = tmp_path / "packet-b"
build_packet(b, out_b, generated_at=_GENERATED_AT)
bundle_b = (out_b / "bundle.json").read_text(encoding="utf-8")
a_fingerprint = a.identity.public().fingerprint
assert a_fingerprint not in bundle_b
assert b.identity.public().fingerprint in bundle_b # producer identity is deliberate
assert _TENANT_FILENAME not in bundle_b
# 3. The vault still RETAINS the audit trail privately — moved, not deleted.
imported = [e for e in b.custody.entries if e.action == "imported"]
assert imported and imported[0].private_details.get("from") == a_fingerprint
def test_packet_ids_do_not_encode_wall_clock_or_node_id(
make_jpeg: Callable[..., Path],
local_tsa: LocalRfc3161TSA,
tmp_path: Path,
) -> None:
"""A shared packet must not encode the device wall clock or the HLC node id in any
exported identifier (issue/capture/timeline) or timestamp field. HLC stays the
internal CRDT ordering key; the externally visible names are opaque, per-case-salted
digests (packet v2). This pins the north-star promise that "nothing leaked"."""
known_ms = 1_767_312_000_000 # a fixed device wall clock the export must never reveal
vault = Vault.create(
tmp_path / "vault", "pw", case_id="guard-4B", unit="4B", time_source=lambda: known_ms
)
node_id = vault.config.node_id
issue = vault.document.add_issue(category="mold", room="bathroom", title="mold")
vault.document.add_timeline_entry(issue, "observed", "mold spreading after roof leak")
vault.save()
capture(vault, make_jpeg("evidence.jpg"), issue_id=issue, tsa=local_tsa)
out = tmp_path / "packet"
build_packet(vault, out, generated_at=_GENERATED_AT)
bundle = (out / "bundle.json").read_text(encoding="utf-8")
# No field encodes the raw HLC (15-digit ms . 6-digit counter . node_id) ...
assert re.search(r"\d{15}\.\d{6}\.", bundle) is None
# ... and neither the wall-clock ms (plain or zero-padded) nor the node id leaks anywhere.
assert str(known_ms) not in bundle
assert f"{known_ms:015d}" not in bundle
assert node_id and node_id not in bundle
# The ids are still present and opaque — the prefix is kept, the wall/node body is not.
assert issue.startswith("issue-")
assert '"cap-' in bundle and '"tl-' in bundle
# The exact module set the Apache-2.0 verification subset is allowed to load:
# verify + the pure helpers it imports, plus the side-effect-free parent package.
_ALLOWED_VERIFIER_MODULES = {
"habitable",
"habitable.canonical",
"habitable.crypto",
"habitable.errors",
"habitable.evidence",
"habitable.tsa",
"habitable.verify",
}
def test_verifier_imports_stay_within_apache_subset() -> None:
"""Importing habitable.verify must not pull in AGPL-only/heavy modules (relay, sync,
cli, packet, pdf, app, capture, vault, ...). Run in a fresh process so an earlier
test that imported those cannot mask a leak."""
probe = (
"import habitable.verify, sys;"
"loaded={m for m in sys.modules if m == 'habitable' or m.startswith('habitable.')};"
f"allowed={sorted(_ALLOWED_VERIFIER_MODULES)!r};"
"extra=sorted(loaded - set(allowed));"
"print('EXTRA:' + ','.join(extra));"
"sys.exit(1 if extra else 0)"
)
result = subprocess.run(
[sys.executable, "-c", probe], capture_output=True, text=True, check=False
)
assert result.returncode == 0, (
f"verifier pulled in non-subset modules: {result.stdout.strip()} {result.stderr.strip()}"
)
# The import closure of habitable.verify — the source an embedder vendors and must be
# able to run on Python < 3.14 (verify.py docstring, NOTICE, docs/embedding-the-verifier.md).
_VERIFIER_SUBSET_FILES = (
"canonical.py",
"crypto.py",
"errors.py",
"evidence.py",
"tsa.py",
"verify.py",
)
_EXCEPT_CLAUSE = re.compile(r"^\s*except\s+([^\n:]+):")
def test_verifier_subset_avoids_py314_only_except_syntax() -> None:
"""The Apache-2.0 verifier subset must avoid PEP 758 parenthesis-free multi-type
`except A, B:` — valid only on Python >= 3.14 and a SyntaxError before it, which would
break legal-aid embedders who vendor the subset onto older interpreters. The ruff
formatter targets py314 and will try to reintroduce it, so this guard fails the gate
if it does; reference a named tuple (e.g. `except _SOME_ERRORS:`) instead."""
src = Path(__file__).resolve().parent.parent / "src" / "habitable"
offenders: list[str] = []
for name in _VERIFIER_SUBSET_FILES:
for lineno, line in enumerate(src.joinpath(name).read_text("utf-8").splitlines(), 1):
match = _EXCEPT_CLAUSE.match(line)
if match is None:
continue
clause = match.group(1).strip()
# `except (A, B):` is portable; `except A, B:` is the 3.14-only form.
if "," in clause and not clause.startswith("("):
offenders.append(f"{name}:{lineno}: {line.strip()}")
assert not offenders, "parenthesis-free multi-type except in verifier subset:\n" + "\n".join(
offenders
)