results-cli 0.4.2__tar.gz → 0.5.0__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.
- {results_cli-0.4.2 → results_cli-0.5.0}/PKG-INFO +28 -2
- {results_cli-0.4.2 → results_cli-0.5.0}/README.md +26 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/plugin/.claude-plugin/plugin.json +1 -1
- {results_cli-0.4.2 → results_cli-0.5.0}/pyproject.toml +2 -2
- {results_cli-0.4.2 → results_cli-0.5.0}/src/results/audit.py +55 -2
- {results_cli-0.4.2 → results_cli-0.5.0}/src/results/cli.py +10 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/src/results/ledger.py +41 -22
- {results_cli-0.4.2 → results_cli-0.5.0}/src/results/record.py +16 -0
- results_cli-0.5.0/src/results/stamps.py +125 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/tests/test_cli.py +22 -0
- results_cli-0.5.0/tests/test_concurrency.py +148 -0
- results_cli-0.5.0/tests/test_timestamp.py +142 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/.gitignore +0 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/LICENSE +0 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/plugin/README.md +0 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/plugin/commands/results-check.md +0 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/plugin/hooks/hooks.json +0 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/plugin/hooks/unbound_numbers.py +0 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/plugin/skills/results/SKILL.md +0 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/src/results/__init__.py +0 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/src/results/manuscript.py +0 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/src/results/paths.py +0 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/src/results/timeline.py +0 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/tests/test_cli_help.py +0 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/tests/test_coverage_and_hook.py +0 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/tests/test_ledger.py +0 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/tests/test_manuscript.py +0 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/tests/test_tamper.py +0 -0
- {results_cli-0.4.2 → results_cli-0.5.0}/tests/test_timeline.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: results-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Seal a run, record what it produced, and verify the chain
|
|
5
5
|
Project-URL: Changelog, https://github.com/elliottower/reproducible-science/blob/main/CHANGELOG.md
|
|
6
6
|
Project-URL: Homepage, https://github.com/elliottower/reproducible-science
|
|
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.14
|
|
18
18
|
Requires-Python: >=3.11
|
|
19
|
-
Requires-Dist: provenance-core<0.
|
|
19
|
+
Requires-Dist: provenance-core[anchor]<0.6,>=0.5
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
|
|
22
22
|
# results
|
|
@@ -77,6 +77,7 @@ all checks passed.
|
|
|
77
77
|
| `results run <file>...` | Record outputs after a run |
|
|
78
78
|
| `results claim <text>` | Bind a manuscript claim to a run |
|
|
79
79
|
| `results verify` | Check the ledger chain and every hash it names |
|
|
80
|
+
| `results timestamp` | Date the ledger's head outside the repository, and check earlier dates |
|
|
80
81
|
|
|
81
82
|
## The chain
|
|
82
83
|
|
|
@@ -119,6 +120,31 @@ Append-only JSONL in `.results/ledger.jsonl`. Each line is hash-chained to the p
|
|
|
119
120
|
or inserting a line breaks the chain. `git diff` shows what changed; `results verify` checks
|
|
120
121
|
whether it should have.
|
|
121
122
|
|
|
123
|
+
`results init` writes a `.results/.gitignore` that ignores only the lock files, so the ledger and
|
|
124
|
+
its anchor are committed with the project. A ledger on one disk is a record nobody else can
|
|
125
|
+
check. What is committed is public with the repository, so a run's `note` is written as a commit
|
|
126
|
+
message would be.
|
|
127
|
+
|
|
128
|
+
## Timestamp
|
|
129
|
+
|
|
130
|
+
The chain catches a line edited by hand. It cannot catch the ledger and its anchor rewritten
|
|
131
|
+
together, because whoever can write one can write the other. `results timestamp` sends the head
|
|
132
|
+
to the [OpenTimestamps](https://opentimestamps.org) calendars, which commit it into a Bitcoin
|
|
133
|
+
block within a few hours, and keeps the proof under `.results/timestamps/`. Each line names the
|
|
134
|
+
hash of the one before it, so a proof of the head dates every earlier event too.
|
|
135
|
+
|
|
136
|
+
```text
|
|
137
|
+
events 1–153 existed by Bitcoin block 915004, 2026-10-03 16:12 UTC
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`results verify` reads the proofs with no network. A proof whose head no longer matches the
|
|
141
|
+
chain at its length means the ledger was rewritten after it was stamped:
|
|
142
|
+
|
|
143
|
+
```text
|
|
144
|
+
TIMESTAMP CONTRADICTS THE CHAIN — the ledger was rewritten after it was stamped
|
|
145
|
+
000153-3f9c2a1b7d4e5f60.ots dates event 153 as 3f9c2a1b7d4e5f60…, and the ledger's event 153 is 81d0…
|
|
146
|
+
```
|
|
147
|
+
|
|
122
148
|
## Claude Code
|
|
123
149
|
|
|
124
150
|
`plugin/` is a Claude Code plugin. Three surfaces, because each catches a different failure:
|
|
@@ -56,6 +56,7 @@ all checks passed.
|
|
|
56
56
|
| `results run <file>...` | Record outputs after a run |
|
|
57
57
|
| `results claim <text>` | Bind a manuscript claim to a run |
|
|
58
58
|
| `results verify` | Check the ledger chain and every hash it names |
|
|
59
|
+
| `results timestamp` | Date the ledger's head outside the repository, and check earlier dates |
|
|
59
60
|
|
|
60
61
|
## The chain
|
|
61
62
|
|
|
@@ -98,6 +99,31 @@ Append-only JSONL in `.results/ledger.jsonl`. Each line is hash-chained to the p
|
|
|
98
99
|
or inserting a line breaks the chain. `git diff` shows what changed; `results verify` checks
|
|
99
100
|
whether it should have.
|
|
100
101
|
|
|
102
|
+
`results init` writes a `.results/.gitignore` that ignores only the lock files, so the ledger and
|
|
103
|
+
its anchor are committed with the project. A ledger on one disk is a record nobody else can
|
|
104
|
+
check. What is committed is public with the repository, so a run's `note` is written as a commit
|
|
105
|
+
message would be.
|
|
106
|
+
|
|
107
|
+
## Timestamp
|
|
108
|
+
|
|
109
|
+
The chain catches a line edited by hand. It cannot catch the ledger and its anchor rewritten
|
|
110
|
+
together, because whoever can write one can write the other. `results timestamp` sends the head
|
|
111
|
+
to the [OpenTimestamps](https://opentimestamps.org) calendars, which commit it into a Bitcoin
|
|
112
|
+
block within a few hours, and keeps the proof under `.results/timestamps/`. Each line names the
|
|
113
|
+
hash of the one before it, so a proof of the head dates every earlier event too.
|
|
114
|
+
|
|
115
|
+
```text
|
|
116
|
+
events 1–153 existed by Bitcoin block 915004, 2026-10-03 16:12 UTC
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`results verify` reads the proofs with no network. A proof whose head no longer matches the
|
|
120
|
+
chain at its length means the ledger was rewritten after it was stamped:
|
|
121
|
+
|
|
122
|
+
```text
|
|
123
|
+
TIMESTAMP CONTRADICTS THE CHAIN — the ledger was rewritten after it was stamped
|
|
124
|
+
000153-3f9c2a1b7d4e5f60.ots dates event 153 as 3f9c2a1b7d4e5f60…, and the ledger's event 153 is 81d0…
|
|
125
|
+
```
|
|
126
|
+
|
|
101
127
|
## Claude Code
|
|
102
128
|
|
|
103
129
|
`plugin/` is a Claude Code plugin. Three surfaces, because each catches a different failure:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "results",
|
|
3
3
|
"description": "Seal a run's inputs, record what it produced, bind every number in a manuscript to the run behind it as it is written, and verify the chain",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Elliot Tower",
|
|
7
7
|
"email": "elliot@elliottower.ai"
|
|
@@ -4,7 +4,7 @@ requires = [ "hatchling" ]
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "results-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Seal a run, record what it produced, and verify the chain"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
keywords = [ "open science", "provenance", "reproducibility", "results" ]
|
|
@@ -18,7 +18,7 @@ classifiers = [
|
|
|
18
18
|
"Programming Language :: Python :: 3.13",
|
|
19
19
|
"Programming Language :: Python :: 3.14",
|
|
20
20
|
]
|
|
21
|
-
dependencies = [ "provenance-core>=0.
|
|
21
|
+
dependencies = [ "provenance-core[anchor]>=0.5,<0.6" ]
|
|
22
22
|
urls.Changelog = "https://github.com/elliottower/reproducible-science/blob/main/CHANGELOG.md"
|
|
23
23
|
urls.Homepage = "https://github.com/elliottower/reproducible-science"
|
|
24
24
|
urls.Issues = "https://github.com/elliottower/reproducible-science/issues"
|
|
@@ -15,9 +15,9 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import pathlib
|
|
17
17
|
|
|
18
|
-
from provenance_core import sha256_of_tree, try_run
|
|
18
|
+
from provenance_core import anchor, sha256_of_tree, try_run
|
|
19
19
|
|
|
20
|
-
from results import ledger, manuscript
|
|
20
|
+
from results import ledger, manuscript, stamps
|
|
21
21
|
from results.paths import ledger_path, require_root
|
|
22
22
|
from results.timeline import first_outcomes_seen, first_run_timestamp, precedes
|
|
23
23
|
|
|
@@ -132,6 +132,37 @@ def reanchor() -> int:
|
|
|
132
132
|
return 0
|
|
133
133
|
|
|
134
134
|
|
|
135
|
+
def timestamp() -> int:
|
|
136
|
+
"""Stamp the current head, complete every pending proof, and check the complete ones."""
|
|
137
|
+
root = require_root()
|
|
138
|
+
lp = ledger_path(root)
|
|
139
|
+
try:
|
|
140
|
+
made = stamps.stamp_head(lp)
|
|
141
|
+
except anchor.AnchorError as e:
|
|
142
|
+
print(f"not timestamped: {e}")
|
|
143
|
+
return 1
|
|
144
|
+
if made is not None:
|
|
145
|
+
print(f"stamped {made.relative_to(root.parent)}")
|
|
146
|
+
try:
|
|
147
|
+
found = stamps.complete(lp)
|
|
148
|
+
except anchor.AnchorError as e:
|
|
149
|
+
print(f"TIMESTAMP {e}")
|
|
150
|
+
return 1
|
|
151
|
+
dated = [(s, block) for s, block in found if block is not None]
|
|
152
|
+
if dated:
|
|
153
|
+
stamp, block = max(dated, key=lambda pair: pair[0].count)
|
|
154
|
+
print(
|
|
155
|
+
f"events 1–{stamp.count} existed by Bitcoin block {block.height}, "
|
|
156
|
+
f"{block.time:%Y-%m-%d %H:%M} UTC"
|
|
157
|
+
)
|
|
158
|
+
waiting = [s for s, block in found if block is None]
|
|
159
|
+
if waiting:
|
|
160
|
+
latest = max(waiting, key=lambda s: s.count)
|
|
161
|
+
print(f"events 1–{latest.count} pending; Bitcoin usually confirms within hours.")
|
|
162
|
+
print("Commit .results/timestamps/ with the ledger.")
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
135
166
|
def verify(check_files: bool) -> int:
|
|
136
167
|
root = require_root()
|
|
137
168
|
lp = ledger_path(root)
|
|
@@ -166,6 +197,28 @@ def verify(check_files: bool) -> int:
|
|
|
166
197
|
if state := in_history(lp):
|
|
167
198
|
print(f" the ledger {state}. A record is evidence once it is in history.\n")
|
|
168
199
|
|
|
200
|
+
reading = stamps.read(lp)
|
|
201
|
+
if reading.contradictions:
|
|
202
|
+
# The chain verifies against its own anchor and not against a proof held outside it, so
|
|
203
|
+
# the chain and the anchor were rewritten together after that proof was made.
|
|
204
|
+
print("TIMESTAMP CONTRADICTS THE CHAIN — the ledger was rewritten after it was stamped")
|
|
205
|
+
for problem in reading.contradictions:
|
|
206
|
+
print(f" {problem}")
|
|
207
|
+
return 1
|
|
208
|
+
if reading.dated:
|
|
209
|
+
print(
|
|
210
|
+
f" events 1–{reading.dated.count} dated by Bitcoin block "
|
|
211
|
+
f"{reading.dated.status.blocks[0]}. `results timestamp` checks the block."
|
|
212
|
+
)
|
|
213
|
+
if reading.pending and (not reading.dated or reading.pending.count > reading.dated.count):
|
|
214
|
+
print(
|
|
215
|
+
f" events 1–{reading.pending.count} stamped, pending at "
|
|
216
|
+
f"{len(reading.pending.status.pending)} calendars."
|
|
217
|
+
)
|
|
218
|
+
if not reading.dated and not reading.pending:
|
|
219
|
+
print(" no outside timestamp. `results timestamp` makes one.")
|
|
220
|
+
print()
|
|
221
|
+
|
|
169
222
|
counts = {}
|
|
170
223
|
for e in events:
|
|
171
224
|
t = e.get("event", "?")
|
|
@@ -8,6 +8,7 @@ results claim <text> bind a manuscript claim to a run's output
|
|
|
8
8
|
results coverage <paper> how many of a manuscript's numbers are bound to a run
|
|
9
9
|
results verify check the ledger chain and every hash it names
|
|
10
10
|
results reanchor record the ledger's current length as authoritative
|
|
11
|
+
results timestamp date the ledger's head outside the repository, and check earlier dates
|
|
11
12
|
|
|
12
13
|
This module parses arguments. Each handler unpacks the namespace argparse built and calls into
|
|
13
14
|
`results.record` or `results.audit`, where the command's logic and its refusals live.
|
|
@@ -76,6 +77,10 @@ def cmd_verify(a) -> int:
|
|
|
76
77
|
return audit.verify(a.files)
|
|
77
78
|
|
|
78
79
|
|
|
80
|
+
def cmd_timestamp(a) -> int:
|
|
81
|
+
return audit.timestamp()
|
|
82
|
+
|
|
83
|
+
|
|
79
84
|
def main(argv: list[str] | None = None) -> int:
|
|
80
85
|
code = _main(argv)
|
|
81
86
|
# After the work, never before it, and never instead of it: the note is about how this
|
|
@@ -165,6 +170,11 @@ def _main(argv: list[str] | None = None) -> int:
|
|
|
165
170
|
ra = sub.add_parser("reanchor", help="record the ledger's current length as authoritative")
|
|
166
171
|
ra.set_defaults(fn=cmd_reanchor)
|
|
167
172
|
|
|
173
|
+
ts = sub.add_parser(
|
|
174
|
+
"timestamp", help="date the ledger's head outside the repository, and check earlier dates"
|
|
175
|
+
)
|
|
176
|
+
ts.set_defaults(fn=cmd_timestamp)
|
|
177
|
+
|
|
168
178
|
a = ap.parse_args(argv)
|
|
169
179
|
if not a.cmd:
|
|
170
180
|
ap.print_help()
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
**Threat model.** The chain detects accidental damage and casual editing: a crashed write, a
|
|
4
4
|
bad sync, a file opened in an editor, a line changed by hand. It does not defend against an
|
|
5
5
|
adversary who can write to the directory, because such an adversary can rewrite the anchor as
|
|
6
|
-
readily as the ledger. Defending against that needs an external anchor
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
readily as the ledger. Defending against that needs an external anchor, and the head digest here
|
|
7
|
+
is what is published to one: `results timestamp` sends it to the OpenTimestamps calendars, and
|
|
8
|
+
`results.stamps` reads the proofs back against the chain.
|
|
9
9
|
|
|
10
10
|
Saying this plainly matters more than the mechanism. A hash chain is often read as proof of
|
|
11
11
|
tamper-resistance when it delivers tamper-*evidence*, and only against a party who does not
|
|
@@ -25,13 +25,12 @@ import enum
|
|
|
25
25
|
import json
|
|
26
26
|
import os
|
|
27
27
|
import pathlib
|
|
28
|
-
import tempfile
|
|
29
28
|
|
|
30
29
|
# Re-exported under the names this module has always used: `ledger.sha256_of_file` and
|
|
31
30
|
# `ledger.ZERO` are its surface, and callers should not have to know where they moved.
|
|
32
31
|
from provenance_core import ZERO as ZERO
|
|
32
|
+
from provenance_core import atomic_write, exclusive_lock, sha256_of_text, shared_lock
|
|
33
33
|
from provenance_core import sha256_of_file as sha256_of_file
|
|
34
|
-
from provenance_core import sha256_of_text
|
|
35
34
|
|
|
36
35
|
LEDGER = "ledger.jsonl"
|
|
37
36
|
ANCHOR = "ledger.head"
|
|
@@ -130,6 +129,11 @@ def last_hash(ledger: pathlib.Path) -> str:
|
|
|
130
129
|
return sha256_of_str(lines[-1]) if lines else ZERO
|
|
131
130
|
|
|
132
131
|
|
|
132
|
+
def line_hashes(ledger: pathlib.Path) -> list[str]:
|
|
133
|
+
"""The digest of each line as stored: entry `n` is the head the anchor records at `n + 1`."""
|
|
134
|
+
return [sha256_of_str(line) for line in _lines(ledger)] if ledger.exists() else []
|
|
135
|
+
|
|
136
|
+
|
|
133
137
|
def read_ledger(ledger: pathlib.Path) -> list[dict]:
|
|
134
138
|
"""Every event, or raise `ChainError` naming the line that is not one."""
|
|
135
139
|
if not ledger.exists():
|
|
@@ -168,29 +172,29 @@ def write_anchor(ledger: pathlib.Path, count: int, head: str) -> dict:
|
|
|
168
172
|
it, which would report a complete ledger as truncated.
|
|
169
173
|
"""
|
|
170
174
|
anchor = {"canon_version": CANON_VERSION, "count": count, "head": head, "updated": now_iso()}
|
|
171
|
-
|
|
175
|
+
atomic_write(anchor_path(ledger), json.dumps(anchor, indent=2, sort_keys=True) + "\n")
|
|
172
176
|
return anchor
|
|
173
177
|
|
|
174
178
|
|
|
175
|
-
def _atomic_write(path: pathlib.Path, text: str) -> None:
|
|
176
|
-
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=path.name, suffix=".tmp")
|
|
177
|
-
try:
|
|
178
|
-
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
179
|
-
f.write(text)
|
|
180
|
-
f.flush()
|
|
181
|
-
os.fsync(f.fileno())
|
|
182
|
-
os.replace(tmp, path)
|
|
183
|
-
except BaseException:
|
|
184
|
-
pathlib.Path(tmp).unlink(missing_ok=True)
|
|
185
|
-
raise
|
|
186
|
-
|
|
187
|
-
|
|
188
179
|
# --------------------------------------------------------------------------------- writing
|
|
189
180
|
|
|
190
181
|
|
|
191
182
|
def append_event(ledger: pathlib.Path, event: dict) -> dict:
|
|
192
183
|
"""Write one event and advance the anchor. Returns a new event; the argument is not
|
|
193
|
-
mutated, so the object a caller holds cannot drift from the line on disk.
|
|
184
|
+
mutated, so the object a caller holds cannot drift from the line on disk.
|
|
185
|
+
|
|
186
|
+
An event's position and `prev_hash` come from the lines already on disk, so two callers that
|
|
187
|
+
read the same tail write two lines claiming one position. The chain then reports `edited`,
|
|
188
|
+
and `reanchor` refuses an edited chain, which leaves no way back: the damage is permanent and
|
|
189
|
+
a pipeline running two skills at once is enough to cause it. The lock covers the read as well
|
|
190
|
+
as the write, because reading early is what makes the second line wrong.
|
|
191
|
+
"""
|
|
192
|
+
with exclusive_lock(ledger):
|
|
193
|
+
return _append_locked(ledger, event)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _append_locked(ledger: pathlib.Path, event: dict) -> dict:
|
|
197
|
+
"""The append itself. Assumes the caller holds the lock for `ledger`."""
|
|
194
198
|
lines = _lines(ledger) if ledger.exists() else []
|
|
195
199
|
|
|
196
200
|
# The anchor is the only witness to the last line, and appending overwrites it. An edited
|
|
@@ -233,7 +237,18 @@ def verify(ledger: pathlib.Path) -> tuple[ChainStatus, list[str]]:
|
|
|
233
237
|
|
|
234
238
|
Reports the first structural fault as the status, because a chain that was edited and then
|
|
235
239
|
truncated is edited: the earlier fault explains the later one.
|
|
240
|
+
|
|
241
|
+
Read under a shared lock. An append writes its line and then its anchor, so a reader arriving
|
|
242
|
+
between the two counted one more event than the anchor records and reported `extended` -- true
|
|
243
|
+
of the bytes, wrong about the cause, and gone on a re-run. A verify that fails only while
|
|
244
|
+
something else is writing teaches people to re-run it until it passes.
|
|
236
245
|
"""
|
|
246
|
+
with shared_lock(ledger):
|
|
247
|
+
return _verify_locked(ledger)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _verify_locked(ledger: pathlib.Path) -> tuple[ChainStatus, list[str]]:
|
|
251
|
+
"""The verification itself. Assumes the caller holds a lock for `ledger`."""
|
|
237
252
|
if not ledger.exists():
|
|
238
253
|
return ChainStatus.ABSENT, [f"{ledger} does not exist"]
|
|
239
254
|
|
|
@@ -314,6 +329,10 @@ def reanchor(ledger: pathlib.Path) -> dict:
|
|
|
314
329
|
For a ledger written before anchoring, and for repairing an under-count after a crash.
|
|
315
330
|
It cannot recover a truncated ledger: re-anchoring a shortened chain records the shortened
|
|
316
331
|
chain, which is why it is a separate deliberate call and not something verification does.
|
|
332
|
+
|
|
333
|
+
Taken under the lock, so it cannot record a length that an append is in the middle of
|
|
334
|
+
changing and leave the anchor describing neither state.
|
|
317
335
|
"""
|
|
318
|
-
|
|
319
|
-
|
|
336
|
+
with exclusive_lock(ledger):
|
|
337
|
+
lines = _lines(ledger) if ledger.exists() else []
|
|
338
|
+
return write_anchor(ledger, len(lines), sha256_of_str(lines[-1]) if lines else ZERO)
|
|
@@ -25,6 +25,20 @@ ACCESS_LEVELS = [
|
|
|
25
25
|
]
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
#: Written into `.results/` so the ledger is tracked without anyone editing the project's own
|
|
29
|
+
#: ignore file. The ledger and its anchor are the record a claim appeals to, and one that exists
|
|
30
|
+
#: on a single disk cannot be appealed to by anyone else; only the lock sidecars are ignored.
|
|
31
|
+
GITIGNORE = """\
|
|
32
|
+
# Commit ledger.jsonl and ledger.head: a record is evidence once it is in history.
|
|
33
|
+
# Everything in the ledger becomes public with the repository, notes included.
|
|
34
|
+
|
|
35
|
+
# Lock sidecars. They hold nothing; the kernel releases a lock when its holder exits.
|
|
36
|
+
*.lock
|
|
37
|
+
*.provenance-lock
|
|
38
|
+
*.provenance-tmp
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
28
42
|
def init() -> int:
|
|
29
43
|
d = pathlib.Path.cwd() / RESULTS_DIR
|
|
30
44
|
if d.exists():
|
|
@@ -34,8 +48,10 @@ def init() -> int:
|
|
|
34
48
|
lp = d / ledger.LEDGER
|
|
35
49
|
lp.touch()
|
|
36
50
|
ledger.append_event(lp, {"event": "init"})
|
|
51
|
+
(d / ".gitignore").write_text(GITIGNORE)
|
|
37
52
|
print(f"created {RESULTS_DIR}/")
|
|
38
53
|
print(f" {ledger.LEDGER} append-only event log")
|
|
54
|
+
print(" .gitignore ignores the lock files only, so the ledger is committed")
|
|
39
55
|
print("\nseal your inputs before running: `results seal prereg.md script.py data.csv`")
|
|
40
56
|
return 0
|
|
41
57
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Outside timestamps of the ledger's head, and what they prove about the chain.
|
|
2
|
+
|
|
3
|
+
The anchor file records how long the chain is and where it ends, and whoever can write the ledger
|
|
4
|
+
can write the anchor. A timestamp of the head is held by the OpenTimestamps calendars and then by
|
|
5
|
+
a Bitcoin block, so it says that a ledger of exactly this length and this last line existed by
|
|
6
|
+
the block's date. Every earlier line is covered too, because each line names the hash of the one
|
|
7
|
+
before it.
|
|
8
|
+
|
|
9
|
+
Proofs live under `.results/timestamps/`, one per stamped head, named by the length and the head
|
|
10
|
+
so a reader can see what each covers without opening it: `000153-3f9c2a…ots` covers events 1 to
|
|
11
|
+
153. They are only ever added. A proof whose head does not match the chain at its length means the
|
|
12
|
+
chain was rewritten after it was stamped, which is the one thing a stamp exists to catch.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import pathlib
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
from provenance_core import anchor, atomic_write_bytes
|
|
22
|
+
|
|
23
|
+
from results import ledger
|
|
24
|
+
|
|
25
|
+
STAMPS = "timestamps"
|
|
26
|
+
NAME = re.compile(r"^(\d{6})-([0-9a-f]{16})\.ots$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Stamp:
|
|
31
|
+
path: pathlib.Path
|
|
32
|
+
count: int
|
|
33
|
+
head: str
|
|
34
|
+
status: anchor.Status
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Reading:
|
|
39
|
+
"""What the proofs under `.results/timestamps/` say about this chain, read with no network."""
|
|
40
|
+
|
|
41
|
+
dated: Stamp | None
|
|
42
|
+
"""The longest stretch of the chain a complete proof covers."""
|
|
43
|
+
pending: Stamp | None
|
|
44
|
+
"""The longest stretch a proof covers that Bitcoin has not confirmed yet."""
|
|
45
|
+
contradictions: list[str]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def stamps_dir(lp: pathlib.Path) -> pathlib.Path:
|
|
49
|
+
return lp.parent / STAMPS
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _proofs(lp: pathlib.Path) -> list[tuple[pathlib.Path, int, str]]:
|
|
53
|
+
found = []
|
|
54
|
+
for path in sorted(stamps_dir(lp).glob("*.ots")):
|
|
55
|
+
m = NAME.match(path.name)
|
|
56
|
+
if m:
|
|
57
|
+
found.append((path, int(m.group(1)), m.group(2)))
|
|
58
|
+
return found
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read(lp: pathlib.Path) -> Reading:
|
|
62
|
+
hashes = ledger.line_hashes(lp)
|
|
63
|
+
dated = pending = None
|
|
64
|
+
contradictions = []
|
|
65
|
+
for path, count, short in _proofs(lp):
|
|
66
|
+
if count > len(hashes):
|
|
67
|
+
contradictions.append(
|
|
68
|
+
f"{path.name} dates a chain of {count} events, and the ledger holds {len(hashes)}"
|
|
69
|
+
)
|
|
70
|
+
continue
|
|
71
|
+
head = hashes[count - 1]
|
|
72
|
+
if not head.startswith(short):
|
|
73
|
+
contradictions.append(
|
|
74
|
+
f"{path.name} dates event {count} as {short}…, and the ledger's event {count} "
|
|
75
|
+
f"is {head[:16]}…"
|
|
76
|
+
)
|
|
77
|
+
continue
|
|
78
|
+
try:
|
|
79
|
+
found = Stamp(path, count, head, anchor.status(path.read_bytes(), head))
|
|
80
|
+
except anchor.AnchorError as e:
|
|
81
|
+
contradictions.append(f"{path.name}: {e}")
|
|
82
|
+
continue
|
|
83
|
+
if found.status.is_complete:
|
|
84
|
+
dated = found if dated is None or count > dated.count else dated
|
|
85
|
+
else:
|
|
86
|
+
pending = found if pending is None or count > pending.count else pending
|
|
87
|
+
return Reading(dated, pending, contradictions)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def stamp_head(lp: pathlib.Path) -> pathlib.Path | None:
|
|
91
|
+
"""Stamp the head the anchor records, or None where that head is already stamped.
|
|
92
|
+
|
|
93
|
+
Refused on a chain that does not verify: a stamp would date the damage.
|
|
94
|
+
"""
|
|
95
|
+
status, problems = ledger.verify(lp)
|
|
96
|
+
if status is not ledger.ChainStatus.INTACT:
|
|
97
|
+
raise ledger.ChainError(
|
|
98
|
+
lp,
|
|
99
|
+
f"refusing to timestamp a chain reported as {status.value}: {'; '.join(problems)}",
|
|
100
|
+
)
|
|
101
|
+
recorded = ledger.read_anchor(lp) or {}
|
|
102
|
+
count, head = recorded["count"], recorded["head"]
|
|
103
|
+
path = stamps_dir(lp) / f"{count:06d}-{head[:16]}.ots"
|
|
104
|
+
if path.exists():
|
|
105
|
+
return None
|
|
106
|
+
path.parent.mkdir(exist_ok=True)
|
|
107
|
+
atomic_write_bytes(path, anchor.stamp(head))
|
|
108
|
+
return path
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def complete(lp: pathlib.Path) -> list[tuple[Stamp, anchor.Confirmation | None]]:
|
|
112
|
+
"""Fetch the rest of every pending proof, and check every complete one against Bitcoin."""
|
|
113
|
+
hashes = ledger.line_hashes(lp)
|
|
114
|
+
out = []
|
|
115
|
+
for path, count, short in _proofs(lp):
|
|
116
|
+
if count > len(hashes) or not hashes[count - 1].startswith(short):
|
|
117
|
+
continue
|
|
118
|
+
head = hashes[count - 1]
|
|
119
|
+
data = anchor.upgrade(path.read_bytes(), head)
|
|
120
|
+
if data != path.read_bytes():
|
|
121
|
+
atomic_write_bytes(path, data)
|
|
122
|
+
found = anchor.status(data, head)
|
|
123
|
+
block = anchor.confirm(data, head) if found.is_complete else None
|
|
124
|
+
out.append((Stamp(path, count, head, found), block))
|
|
125
|
+
return out
|
|
@@ -28,6 +28,28 @@ def test_init_creates_results_dir(tmp_path):
|
|
|
28
28
|
assert events[0]["event"] == "init"
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def test_a_new_ledger_is_tracked_by_git_and_its_lock_files_are_not(tmp_path):
|
|
32
|
+
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True, env=clean_env())
|
|
33
|
+
run_cli("init", cwd=tmp_path)
|
|
34
|
+
(tmp_path / "script.py").write_text("print('hello')\n")
|
|
35
|
+
run_cli("seal", "script.py", cwd=tmp_path)
|
|
36
|
+
(tmp_path / ".results" / "ledger.jsonl.lock").touch()
|
|
37
|
+
(tmp_path / ".results" / "ledger.jsonl.provenance-lock").touch()
|
|
38
|
+
|
|
39
|
+
def ignored(name):
|
|
40
|
+
return (
|
|
41
|
+
subprocess.run(
|
|
42
|
+
["git", "check-ignore", "-q", f".results/{name}"], cwd=tmp_path, env=clean_env()
|
|
43
|
+
).returncode
|
|
44
|
+
== 0
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
assert not ignored("ledger.jsonl")
|
|
48
|
+
assert not ignored("ledger.head")
|
|
49
|
+
assert ignored("ledger.jsonl.lock")
|
|
50
|
+
assert ignored("ledger.jsonl.provenance-lock")
|
|
51
|
+
|
|
52
|
+
|
|
31
53
|
def test_init_twice_fails(tmp_path):
|
|
32
54
|
run_cli("init", cwd=tmp_path)
|
|
33
55
|
r = run_cli("init", cwd=tmp_path)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Concurrent appends to one ledger.
|
|
2
|
+
|
|
3
|
+
Two callers that read the same tail compute the same `seq` and the same `prev_hash`, so the chain
|
|
4
|
+
takes two lines claiming one position. Verification reports `edited`, and `reanchor` refuses an
|
|
5
|
+
edited chain, which leaves the ledger permanently unrepairable. An orchestrator running two
|
|
6
|
+
analyses at once is enough to cause it, so these assert the chain survives contention rather than
|
|
7
|
+
that the lock exists.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
|
|
14
|
+
from results import ledger
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _append_from_threads(lp, threads: int, per_thread: int) -> list[BaseException]:
|
|
18
|
+
failures: list[BaseException] = []
|
|
19
|
+
barrier = threading.Barrier(threads)
|
|
20
|
+
|
|
21
|
+
def worker(n: int) -> None:
|
|
22
|
+
barrier.wait(timeout=30)
|
|
23
|
+
for i in range(per_thread):
|
|
24
|
+
try:
|
|
25
|
+
ledger.append_event(lp, {"event": "run", "thread": n, "i": i})
|
|
26
|
+
except BaseException as e:
|
|
27
|
+
failures.append(e)
|
|
28
|
+
|
|
29
|
+
workers = [threading.Thread(target=worker, args=(n,)) for n in range(threads)]
|
|
30
|
+
for w in workers:
|
|
31
|
+
w.start()
|
|
32
|
+
for w in workers:
|
|
33
|
+
w.join(timeout=60)
|
|
34
|
+
return failures
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_concurrent_appends_leave_an_intact_chain(tmp_path):
|
|
38
|
+
lp = tmp_path / "ledger.jsonl"
|
|
39
|
+
lp.touch()
|
|
40
|
+
threads, per_thread = 16, 8
|
|
41
|
+
|
|
42
|
+
failures = _append_from_threads(lp, threads, per_thread)
|
|
43
|
+
|
|
44
|
+
assert failures == []
|
|
45
|
+
status, problems = ledger.verify(lp)
|
|
46
|
+
assert (status, problems) == (ledger.ChainStatus.INTACT, [])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_concurrent_appends_lose_no_event(tmp_path):
|
|
50
|
+
lp = tmp_path / "ledger.jsonl"
|
|
51
|
+
lp.touch()
|
|
52
|
+
threads, per_thread = 16, 8
|
|
53
|
+
|
|
54
|
+
_append_from_threads(lp, threads, per_thread)
|
|
55
|
+
|
|
56
|
+
events = ledger.read_ledger(lp)
|
|
57
|
+
assert len(events) == threads * per_thread
|
|
58
|
+
assert [e["seq"] for e in events] == list(range(threads * per_thread))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_every_concurrent_event_is_recorded_once(tmp_path):
|
|
62
|
+
lp = tmp_path / "ledger.jsonl"
|
|
63
|
+
lp.touch()
|
|
64
|
+
threads, per_thread = 16, 8
|
|
65
|
+
|
|
66
|
+
_append_from_threads(lp, threads, per_thread)
|
|
67
|
+
|
|
68
|
+
events = ledger.read_ledger(lp)
|
|
69
|
+
written = sorted((e["thread"], e["i"]) for e in events)
|
|
70
|
+
expected = sorted((n, i) for n in range(threads) for i in range(per_thread))
|
|
71
|
+
assert written == expected
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_the_anchor_matches_the_ledger_after_contention(tmp_path):
|
|
75
|
+
lp = tmp_path / "ledger.jsonl"
|
|
76
|
+
lp.touch()
|
|
77
|
+
|
|
78
|
+
_append_from_threads(lp, 12, 6)
|
|
79
|
+
|
|
80
|
+
anchor = ledger.read_anchor(lp)
|
|
81
|
+
lines = lp.read_text(encoding="utf-8").splitlines()
|
|
82
|
+
assert anchor["count"] == len(lines)
|
|
83
|
+
assert anchor["head"] == ledger.sha256_of_str(lines[-1])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_a_reanchor_racing_appends_leaves_the_chain_verifiable(tmp_path):
|
|
87
|
+
lp = tmp_path / "ledger.jsonl"
|
|
88
|
+
lp.touch()
|
|
89
|
+
ledger.append_event(lp, {"event": "init"})
|
|
90
|
+
stop = threading.Event()
|
|
91
|
+
|
|
92
|
+
def reanchor_repeatedly() -> None:
|
|
93
|
+
while not stop.is_set():
|
|
94
|
+
ledger.reanchor(lp)
|
|
95
|
+
|
|
96
|
+
spinner = threading.Thread(target=reanchor_repeatedly, daemon=True)
|
|
97
|
+
spinner.start()
|
|
98
|
+
try:
|
|
99
|
+
_append_from_threads(lp, 8, 6)
|
|
100
|
+
finally:
|
|
101
|
+
stop.set()
|
|
102
|
+
spinner.join(timeout=10)
|
|
103
|
+
|
|
104
|
+
ledger.reanchor(lp)
|
|
105
|
+
status, problems = ledger.verify(lp)
|
|
106
|
+
assert (status, problems) == (ledger.ChainStatus.INTACT, [])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
APPEND_IN_CHILD = """
|
|
110
|
+
import sys
|
|
111
|
+
from pathlib import Path
|
|
112
|
+
from results import ledger
|
|
113
|
+
|
|
114
|
+
lp, n = Path(sys.argv[1]), int(sys.argv[2])
|
|
115
|
+
for i in range(n):
|
|
116
|
+
ledger.append_event(lp, {"event": "run", "proc": sys.argv[3], "i": i})
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_separate_processes_appending_leave_an_intact_chain(tmp_path):
|
|
121
|
+
# The case this guards against is two processes, not two threads: an orchestrator running two
|
|
122
|
+
# skills, or a rerun started before the last one finished. `flock` is held per open file
|
|
123
|
+
# description, so a threaded test exercises the same exclusion -- but only a process test
|
|
124
|
+
# shows it holding across the interpreter boundary the real failure crosses.
|
|
125
|
+
import subprocess
|
|
126
|
+
import sys
|
|
127
|
+
|
|
128
|
+
lp = tmp_path / "ledger.jsonl"
|
|
129
|
+
lp.touch()
|
|
130
|
+
script = tmp_path / "append.py"
|
|
131
|
+
script.write_text(APPEND_IN_CHILD)
|
|
132
|
+
per_process, processes = 6, 6
|
|
133
|
+
|
|
134
|
+
running = [
|
|
135
|
+
subprocess.Popen(
|
|
136
|
+
[sys.executable, str(script), str(lp), str(per_process), f"p{n}"],
|
|
137
|
+
stdout=subprocess.PIPE,
|
|
138
|
+
stderr=subprocess.PIPE,
|
|
139
|
+
)
|
|
140
|
+
for n in range(processes)
|
|
141
|
+
]
|
|
142
|
+
for proc in running:
|
|
143
|
+
_, err = proc.communicate(timeout=120)
|
|
144
|
+
assert proc.returncode == 0, err.decode()[-2000:]
|
|
145
|
+
|
|
146
|
+
status, problems = ledger.verify(lp)
|
|
147
|
+
assert (status, problems) == (ledger.ChainStatus.INTACT, [])
|
|
148
|
+
assert len(ledger.read_ledger(lp)) == per_process * processes
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""A stamped head catches what the anchor cannot: the ledger and its anchor rewritten together."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from opentimestamps.core.notary import BitcoinBlockHeaderAttestation, PendingAttestation
|
|
9
|
+
from opentimestamps.core.op import OpSHA256
|
|
10
|
+
from opentimestamps.core.timestamp import Timestamp
|
|
11
|
+
from provenance_core import anchor
|
|
12
|
+
from results import cli, ledger
|
|
13
|
+
|
|
14
|
+
ALICE = "https://alice.btc.calendar.opentimestamps.org"
|
|
15
|
+
BOB = "https://bob.btc.calendar.opentimestamps.org"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Calendars:
|
|
19
|
+
"""Both calendars. Each accepts a commitment; `mine(height)` makes them complete it."""
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.height: int | None = None
|
|
23
|
+
self.roots: dict[int, str] = {}
|
|
24
|
+
|
|
25
|
+
def mine(self, height: int) -> None:
|
|
26
|
+
self.height = height
|
|
27
|
+
|
|
28
|
+
def __call__(self, url: str):
|
|
29
|
+
calendars = self
|
|
30
|
+
|
|
31
|
+
class One:
|
|
32
|
+
def __init__(self):
|
|
33
|
+
self.url = url
|
|
34
|
+
|
|
35
|
+
def submit(self, digest, timeout=None):
|
|
36
|
+
timestamp = Timestamp(digest)
|
|
37
|
+
timestamp.attestations.add(PendingAttestation(url))
|
|
38
|
+
return timestamp
|
|
39
|
+
|
|
40
|
+
def get_timestamp(self, commitment, timeout=None):
|
|
41
|
+
if calendars.height is None:
|
|
42
|
+
raise anchor.CommitmentNotFoundError("not yet")
|
|
43
|
+
timestamp = Timestamp(commitment)
|
|
44
|
+
root = timestamp.ops.add(OpSHA256())
|
|
45
|
+
root.attestations.add(BitcoinBlockHeaderAttestation(calendars.height))
|
|
46
|
+
calendars.roots[calendars.height] = root.msg[::-1].hex()
|
|
47
|
+
return timestamp
|
|
48
|
+
|
|
49
|
+
return One()
|
|
50
|
+
|
|
51
|
+
def explorer(self, url: str) -> bytes:
|
|
52
|
+
if "/block-height/" in url:
|
|
53
|
+
return f"hash{url.rsplit('/', 1)[1]}".encode()
|
|
54
|
+
height = int(url.rsplit("hash", 1)[1])
|
|
55
|
+
return json.dumps({"merkle_root": self.roots[height], "timestamp": 1_759_500_000}).encode()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.fixture
|
|
59
|
+
def calendars(monkeypatch) -> Calendars:
|
|
60
|
+
fake = Calendars()
|
|
61
|
+
monkeypatch.setenv(anchor.CALENDARS_ENV, f"{ALICE},{BOB}")
|
|
62
|
+
monkeypatch.setattr(anchor, "remote", fake)
|
|
63
|
+
monkeypatch.setattr(anchor, "_get", fake.explorer)
|
|
64
|
+
return fake
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture
|
|
68
|
+
def project(tmp_path, monkeypatch):
|
|
69
|
+
monkeypatch.chdir(tmp_path)
|
|
70
|
+
(tmp_path / "data.csv").write_text("a,b\n1,2\n")
|
|
71
|
+
assert cli.main(["init"]) == 0
|
|
72
|
+
assert cli.main(["seal", "data.csv"]) == 0
|
|
73
|
+
return tmp_path / ".results" / "ledger.jsonl"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_a_stamp_is_pending_until_mined_and_then_dates_the_whole_chain(project, calendars, capsys):
|
|
77
|
+
assert cli.main(["timestamp"]) == 0
|
|
78
|
+
capsys.readouterr()
|
|
79
|
+
cli.main(["verify"])
|
|
80
|
+
assert "events 1–2 stamped, pending at 2 calendars" in capsys.readouterr().out
|
|
81
|
+
|
|
82
|
+
calendars.mine(915_000)
|
|
83
|
+
assert cli.main(["timestamp"]) == 0
|
|
84
|
+
assert "events 1–2 existed by Bitcoin block 915000" in capsys.readouterr().out
|
|
85
|
+
assert cli.main(["verify"]) == 0
|
|
86
|
+
assert "events 1–2 dated by Bitcoin block 915000" in capsys.readouterr().out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_stamping_the_same_head_twice_makes_one_proof(project, calendars):
|
|
90
|
+
cli.main(["timestamp"])
|
|
91
|
+
cli.main(["timestamp"])
|
|
92
|
+
|
|
93
|
+
assert len(list((project.parent / "timestamps").glob("*.ots"))) == 1
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_a_ledger_rewritten_with_its_anchor_after_a_stamp_is_reported(project, calendars, capsys):
|
|
97
|
+
cli.main(["timestamp"])
|
|
98
|
+
# The owner's rewrite: same length, a different second event, chain and anchor both
|
|
99
|
+
# consistent. `results verify` alone passes this.
|
|
100
|
+
project.unlink()
|
|
101
|
+
ledger.anchor_path(project).unlink()
|
|
102
|
+
(project.parent.parent / "data.csv").write_text("a,b\n9,9\n")
|
|
103
|
+
ledger.append_event(project, {"event": "init"})
|
|
104
|
+
ledger.append_event(project, {"event": "seal", "files": []})
|
|
105
|
+
capsys.readouterr()
|
|
106
|
+
|
|
107
|
+
assert cli.main(["verify"]) == 1
|
|
108
|
+
out = capsys.readouterr().out
|
|
109
|
+
assert "TIMESTAMP CONTRADICTS THE CHAIN" in out
|
|
110
|
+
assert "000002-" in out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_a_ledger_truncated_and_reanchored_after_a_stamp_is_reported(project, calendars, capsys):
|
|
114
|
+
(project.parent.parent / "more.csv").write_text("x\n")
|
|
115
|
+
cli.main(["seal", "more.csv"])
|
|
116
|
+
cli.main(["timestamp"])
|
|
117
|
+
lines = project.read_text().splitlines(keepends=True)
|
|
118
|
+
project.write_text("".join(lines[:-1]))
|
|
119
|
+
ledger.anchor_path(project).unlink()
|
|
120
|
+
assert cli.main(["reanchor"]) == 0
|
|
121
|
+
capsys.readouterr()
|
|
122
|
+
|
|
123
|
+
assert cli.main(["verify"]) == 1
|
|
124
|
+
assert "dates a chain of 3 events, and the ledger holds 2" in capsys.readouterr().out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_a_damaged_chain_is_not_stamped(project, calendars, capsys):
|
|
128
|
+
project.write_text(project.read_text().replace('"seal"', '"sealed"'))
|
|
129
|
+
|
|
130
|
+
assert cli.main(["timestamp"]) == 2
|
|
131
|
+
assert not (project.parent / "timestamps").exists()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_with_no_calendar_reachable_nothing_is_written_and_verify_says_there_is_no_date(
|
|
135
|
+
project, monkeypatch, capsys
|
|
136
|
+
):
|
|
137
|
+
monkeypatch.setenv(anchor.CALENDARS_ENV, "")
|
|
138
|
+
|
|
139
|
+
assert cli.main(["timestamp"]) == 1
|
|
140
|
+
capsys.readouterr()
|
|
141
|
+
cli.main(["verify"])
|
|
142
|
+
assert "no outside timestamp" in capsys.readouterr().out
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|