ledgdex 1.0.20__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.
- ledgdex/__init__.py +2 -0
- ledgdex/__main__.py +3 -0
- ledgdex/canon.py +99 -0
- ledgdex/check.py +352 -0
- ledgdex/cli.py +559 -0
- ledgdex/core.py +535 -0
- ledgdex/dex.py +292 -0
- ledgdex/ed25519.py +135 -0
- ledgdex/publish.py +79 -0
- ledgdex/render.py +352 -0
- ledgdex/shop.py +217 -0
- ledgdex/sig.py +88 -0
- ledgdex/state.py +278 -0
- ledgdex/styles.css +123 -0
- ledgdex-1.0.20.dist-info/METADATA +254 -0
- ledgdex-1.0.20.dist-info/RECORD +20 -0
- ledgdex-1.0.20.dist-info/WHEEL +5 -0
- ledgdex-1.0.20.dist-info/entry_points.txt +2 -0
- ledgdex-1.0.20.dist-info/licenses/LICENSE +121 -0
- ledgdex-1.0.20.dist-info/top_level.txt +1 -0
ledgdex/__init__.py
ADDED
ledgdex/__main__.py
ADDED
ledgdex/canon.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Canonical JSON (spec 1.2): the exact bytes that are hashed and signed."""
|
|
2
|
+
import hashlib, json, re
|
|
3
|
+
|
|
4
|
+
KEY = re.compile(r'[a-z][a-z0-9_]*\Z')
|
|
5
|
+
ID_KEY = re.compile(r'[\x21-\x7e]+\Z') # state output (spec 6.3) is keyed by ids: printable ASCII
|
|
6
|
+
MAX_INT = 2 ** 53 - 1
|
|
7
|
+
MAX_DEPTH = 32 # spec 1.2 rule 9: arrays and objects nest at most this deep
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CanonError(ValueError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check(v, keys=KEY, depth=1):
|
|
15
|
+
"""Raise CanonError unless v follows rules 1-3 and 9."""
|
|
16
|
+
if v is None or isinstance(v, bool):
|
|
17
|
+
return
|
|
18
|
+
if isinstance(v, int):
|
|
19
|
+
if not -MAX_INT <= v <= MAX_INT:
|
|
20
|
+
raise CanonError('integer out of range')
|
|
21
|
+
return
|
|
22
|
+
if isinstance(v, float):
|
|
23
|
+
raise CanonError('floats are not allowed')
|
|
24
|
+
if isinstance(v, str):
|
|
25
|
+
try:
|
|
26
|
+
v.encode('utf-8')
|
|
27
|
+
except UnicodeEncodeError:
|
|
28
|
+
raise CanonError('string is not valid Unicode')
|
|
29
|
+
return
|
|
30
|
+
if isinstance(v, (list, dict)) and depth > MAX_DEPTH:
|
|
31
|
+
raise CanonError('too deeply nested')
|
|
32
|
+
if isinstance(v, list):
|
|
33
|
+
for x in v:
|
|
34
|
+
check(x, keys, depth + 1)
|
|
35
|
+
return
|
|
36
|
+
if isinstance(v, dict):
|
|
37
|
+
for k, x in v.items():
|
|
38
|
+
if not isinstance(k, str) or not keys.match(k):
|
|
39
|
+
raise CanonError('bad key: ' + str(k))
|
|
40
|
+
check(x, keys, depth + 1)
|
|
41
|
+
return
|
|
42
|
+
raise CanonError('value of type ' + type(v).__name__ + ' is not allowed')
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def canon(v, keys=KEY):
|
|
46
|
+
"""canon(x): canonical JSON bytes. keys=ID_KEY only for the state object, whose keys are ids."""
|
|
47
|
+
check(v, keys)
|
|
48
|
+
return json.dumps(v, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode('utf-8')
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def sha256(data):
|
|
52
|
+
return 'sha256:' + hashlib.sha256(data).hexdigest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def hash_(v):
|
|
56
|
+
"""hash(x) = "sha256:" + hex(SHA-256(canon(x)))."""
|
|
57
|
+
return sha256(canon(v))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _pairs(pairs):
|
|
61
|
+
d = {}
|
|
62
|
+
for k, v in pairs:
|
|
63
|
+
if k in d:
|
|
64
|
+
raise CanonError('duplicate key: ' + k)
|
|
65
|
+
d[k] = v
|
|
66
|
+
return d
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _no_float(s):
|
|
70
|
+
raise CanonError('floats are not allowed')
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _int(s):
|
|
74
|
+
# refuse a long integer before converting it: converting a huge one takes quadratic time on older Pythons
|
|
75
|
+
if len(s.lstrip('-')) > 16:
|
|
76
|
+
raise CanonError('integer out of range')
|
|
77
|
+
return int(s)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _no_constant(s):
|
|
81
|
+
raise CanonError(s + ' is not allowed')
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse(data):
|
|
85
|
+
"""Parse bytes that MUST already be canonical JSON. Returns the value or raises CanonError."""
|
|
86
|
+
if isinstance(data, str):
|
|
87
|
+
data = data.encode('utf-8')
|
|
88
|
+
try:
|
|
89
|
+
v = json.loads(data.decode('utf-8'), object_pairs_hook=_pairs,
|
|
90
|
+
parse_float=_no_float, parse_int=_int, parse_constant=_no_constant)
|
|
91
|
+
except CanonError:
|
|
92
|
+
raise
|
|
93
|
+
except RecursionError:
|
|
94
|
+
raise CanonError('too deeply nested')
|
|
95
|
+
except ValueError as e:
|
|
96
|
+
raise CanonError('not JSON: ' + str(e))
|
|
97
|
+
if canon(v) != data:
|
|
98
|
+
raise CanonError('not canonical JSON')
|
|
99
|
+
return v
|
ledgdex/check.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Tamper checks over time (spec 3.4, 9): run by hand, from cron, as a worker, or in a GitHub workflow.
|
|
2
|
+
|
|
3
|
+
A ledger that verifies is internally consistent and signed by its owner. These checks add what one verification
|
|
4
|
+
cannot see: whether a ledger still extends the copy seen last time, whether every receipt still matches the ledger
|
|
5
|
+
it came from, whether the same address still serves the same ledger, and (optionally) whether offer media still match
|
|
6
|
+
their hashes. Errors mean tampering or equivocation and come with signed proof where there is one. Warnings mean
|
|
7
|
+
something could not be checked (a site is down)."""
|
|
8
|
+
import datetime, hashlib, json, os, re
|
|
9
|
+
from .canon import hash_
|
|
10
|
+
from .core import KEY_TYPES, SKEW, Invalid, Keys, Ledger, now, seconds
|
|
11
|
+
from .dex import LEDGER, fetch, http_get, inside
|
|
12
|
+
|
|
13
|
+
STATE = '.ledgdex-check'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Store:
|
|
17
|
+
"""Last-seen copies of ledgers and which ledger each address served. Lives outside every dex's gen/."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, root):
|
|
20
|
+
self.root = root
|
|
21
|
+
os.makedirs(os.path.join(root, 'seen'), exist_ok=True)
|
|
22
|
+
os.makedirs(os.path.join(root, 'proofs'), exist_ok=True)
|
|
23
|
+
self.urls_path = os.path.join(root, 'urls.json')
|
|
24
|
+
self.urls = {}
|
|
25
|
+
if os.path.exists(self.urls_path):
|
|
26
|
+
with open(self.urls_path, encoding='utf-8') as f:
|
|
27
|
+
self.urls = json.load(f)
|
|
28
|
+
|
|
29
|
+
def _seen(self, ledger_id):
|
|
30
|
+
return os.path.join(self.root, 'seen', ledger_id[7:] + '.jsonl')
|
|
31
|
+
|
|
32
|
+
def seen(self, ledger_id):
|
|
33
|
+
p = self._seen(ledger_id)
|
|
34
|
+
if not os.path.exists(p):
|
|
35
|
+
return None
|
|
36
|
+
with open(p, 'rb') as f:
|
|
37
|
+
return f.read()
|
|
38
|
+
|
|
39
|
+
def remember(self, led):
|
|
40
|
+
data = led.header_line + b'\n' + b''.join(line + b'\n' for line in led.lines)
|
|
41
|
+
old = self.seen(led.id)
|
|
42
|
+
if old is None or len(data) > len(old):
|
|
43
|
+
with open(self._seen(led.id), 'wb') as f:
|
|
44
|
+
f.write(data)
|
|
45
|
+
|
|
46
|
+
def proof(self, name, obj):
|
|
47
|
+
p = os.path.join(self.root, 'proofs', name + '.json')
|
|
48
|
+
with open(p, 'w', encoding='utf-8', newline='\n') as f:
|
|
49
|
+
f.write(json.dumps(obj, indent=2, ensure_ascii=False))
|
|
50
|
+
return p
|
|
51
|
+
|
|
52
|
+
def save(self):
|
|
53
|
+
with open(self.urls_path, 'w', encoding='utf-8', newline='\n') as f:
|
|
54
|
+
f.write(json.dumps(self.urls, indent=2, sort_keys=True))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Checker:
|
|
58
|
+
def __init__(self, state_dir=STATE, media=False, log=print, root=None):
|
|
59
|
+
self.store = Store(state_dir)
|
|
60
|
+
self.media = media
|
|
61
|
+
self.log = log
|
|
62
|
+
self.problems = []
|
|
63
|
+
self.root, self.root_error = None, None # the root: needed for ledgers with a "recovered" entry
|
|
64
|
+
if root:
|
|
65
|
+
try:
|
|
66
|
+
self.root = fetch(root, cache=False)[0]
|
|
67
|
+
except Exception as e: # the root is down: say so, and do not call ledgers that need it broken
|
|
68
|
+
self.root_error = str(e)
|
|
69
|
+
self.problem('warning', 'unreachable', root, 'the root: ' + str(e))
|
|
70
|
+
self.checked = []
|
|
71
|
+
self.copies = {} # ledger id -> [(address, Ledger)] checked in this run
|
|
72
|
+
|
|
73
|
+
def problem(self, level, code, url, detail, ledger=None, proof=None):
|
|
74
|
+
p = {'level': level, 'code': code, 'url': url, 'detail': detail}
|
|
75
|
+
if ledger:
|
|
76
|
+
p['ledger'] = ledger
|
|
77
|
+
if proof:
|
|
78
|
+
p['proof'] = proof
|
|
79
|
+
self.problems.append(p)
|
|
80
|
+
self.log(level.upper() + ' ' + code + ': ' + url + ': ' + detail + (' (proof: ' + proof + ')' if proof else ''))
|
|
81
|
+
|
|
82
|
+
def load(self, src, level='error', remote=False):
|
|
83
|
+
# never the verification cache: a check verifies every entry, every time
|
|
84
|
+
try:
|
|
85
|
+
led, _ = fetch(src, cache=False, root=self.root, remote=remote)
|
|
86
|
+
return led
|
|
87
|
+
except Exception as e: # unreachable, missing, not a ledger
|
|
88
|
+
self.problem(level, 'unreachable', src, str(e))
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
def ledger(self, led, url):
|
|
92
|
+
"""Checks for one ledger at one address."""
|
|
93
|
+
if led.header is None:
|
|
94
|
+
self.problem('error', 'broken', url, str(led.error))
|
|
95
|
+
return False
|
|
96
|
+
ok = True
|
|
97
|
+
if not led.whole and (self.root is None) and 'can only be verified with the root' in str(led.error):
|
|
98
|
+
# not tampering: this ledger was recovered through the root, and the root could not be read (or none was
|
|
99
|
+
# given); check it again with the root
|
|
100
|
+
self.problem('warning', 'needs_root', url, 'entry ' + str(led.broken_at) + ' needs the root to verify' +
|
|
101
|
+
(' (the root could not be read)' if self.root_error else ': run with --root'), led.id)
|
|
102
|
+
return True
|
|
103
|
+
if not led.whole:
|
|
104
|
+
self.problem('error', 'broken', url, 'broken at seq ' + str(led.broken_at) + ': ' + str(led.error), led.id)
|
|
105
|
+
ok = False
|
|
106
|
+
if led.entries and seconds(led.entries[-1]['time']) > seconds(now()) + SKEW:
|
|
107
|
+
# an owner stamping entries ahead of time: every later entry must follow it, and it plays with any rule
|
|
108
|
+
# that compares times across ledgers
|
|
109
|
+
self.problem('warning', 'future_time', url, 'entries are timed up to ' + led.entries[-1]['time'] +
|
|
110
|
+
', ahead of now', led.id)
|
|
111
|
+
before = self.store.urls.get(url)
|
|
112
|
+
if before and before != led.id:
|
|
113
|
+
self.problem('error', 'ledger_changed', url, 'this address served ledger ' + before + ' before and now '
|
|
114
|
+
'serves ' + led.id, led.id)
|
|
115
|
+
ok = False
|
|
116
|
+
old = self.store.seen(led.id)
|
|
117
|
+
if old is not None and not led.data.startswith(old):
|
|
118
|
+
ok = self.rewritten(led, url, old) and ok
|
|
119
|
+
elif ok:
|
|
120
|
+
self.store.remember(led)
|
|
121
|
+
if not before:
|
|
122
|
+
self.store.urls[url] = led.id
|
|
123
|
+
if self.media:
|
|
124
|
+
self.check_media(led, url)
|
|
125
|
+
self.checked.append({'url': url, 'ledger': led.id, 'entries': len(led.entries), 'whole': led.whole})
|
|
126
|
+
self.copies.setdefault(led.id, []).append((url, led))
|
|
127
|
+
return ok
|
|
128
|
+
|
|
129
|
+
def rewritten(self, led, url, old):
|
|
130
|
+
prev = Ledger(old, cache=False)
|
|
131
|
+
n = 0
|
|
132
|
+
while n < len(prev.lines) and n < len(led.lines) and prev.lines[n] == led.lines[n]:
|
|
133
|
+
n += 1
|
|
134
|
+
if n < len(prev.lines) and n < len(led.lines):
|
|
135
|
+
# two entries at one seq, both signed by the owner (spec 3.4)
|
|
136
|
+
proof = self.store.proof('equivocation-' + led.id[7:19] + '-' + str(n), {
|
|
137
|
+
'kind': 'equivocation', 'ledger': led.id, 'header': led.header, 'seq': n,
|
|
138
|
+
'seen': json.loads(prev.lines[n]), 'now': json.loads(led.lines[n]),
|
|
139
|
+
'note': 'Two entries at the same seq of one ledger, both signed by its owner key.'})
|
|
140
|
+
self.problem('error', 'equivocation', url, 'seq ' + str(n) + ' differs from the copy seen before', led.id,
|
|
141
|
+
proof)
|
|
142
|
+
return False
|
|
143
|
+
# an exact start of the copy seen before: an old snapshot, a cache, or entries withheld. Not a rewrite: every
|
|
144
|
+
# entry served is one seen before, so nothing signed has changed.
|
|
145
|
+
self.problem('warning', 'stale', url, 'serves ' + str(n) + ' of the ' + str(len(prev.lines)) +
|
|
146
|
+
' entries seen before', led.id)
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
def check_media(self, led, url):
|
|
150
|
+
for e in led.entries:
|
|
151
|
+
if e['msg']['type'] != 'offer':
|
|
152
|
+
continue
|
|
153
|
+
for m in e['msg']['body']['item']['media']:
|
|
154
|
+
try:
|
|
155
|
+
got = 'sha256:' + hashlib.sha256(http_get(m['url'])).hexdigest()
|
|
156
|
+
except Exception as err:
|
|
157
|
+
self.problem('warning', 'media_unavailable', m['url'], str(err), led.id)
|
|
158
|
+
continue
|
|
159
|
+
if got != m['hash']:
|
|
160
|
+
self.problem('error', 'media_changed', m['url'], 'hash is ' + got + ', the offer signed ' +
|
|
161
|
+
m['hash'], led.id)
|
|
162
|
+
|
|
163
|
+
def receipts(self, led, url):
|
|
164
|
+
"""Every receipt in led must match every copy of the ledger it came from: the copies at the receipt's address
|
|
165
|
+
and the ledger's own dex address, copies already checked in this run, and the copy seen before."""
|
|
166
|
+
groups = {}
|
|
167
|
+
for e in led.entries:
|
|
168
|
+
if e['msg']['type'] == 'receipt':
|
|
169
|
+
b = e['msg']['body']
|
|
170
|
+
g = groups.setdefault(b['ledger'], {'urls': [], 'receipts': []})
|
|
171
|
+
if b['url'] not in g['urls']:
|
|
172
|
+
g['urls'].append(b['url'])
|
|
173
|
+
g['receipts'].append(b)
|
|
174
|
+
for ledger_id, g in groups.items():
|
|
175
|
+
# one address per place: a local folder or file named relatively in a receipt is the same as its full path
|
|
176
|
+
urls = []
|
|
177
|
+
for u in g['urls']:
|
|
178
|
+
u = u if re.match(r'https?://', u) or ':' in u.split('/')[0] else os.path.abspath(u)
|
|
179
|
+
if u not in urls:
|
|
180
|
+
urls.append(u)
|
|
181
|
+
for u in urls:
|
|
182
|
+
if any(u == c_url for c_url, _ in self.copies.get(ledger_id, [])):
|
|
183
|
+
continue
|
|
184
|
+
other = self.load(u, level='warning', remote=True)
|
|
185
|
+
if other is None or other.header is None:
|
|
186
|
+
continue
|
|
187
|
+
dex = other.dex
|
|
188
|
+
if re.match(r'https?://', dex) and dex not in urls:
|
|
189
|
+
urls.append(dex) # also check the ledger at its own dex address
|
|
190
|
+
if other.id != ledger_id:
|
|
191
|
+
# the address in a receipt is written by the receipt's holder, not by the ledger's owner: it is
|
|
192
|
+
# evidence against that address only if the address served this ledger before
|
|
193
|
+
if self.store.urls.get(u) == ledger_id:
|
|
194
|
+
self.problem('error', 'ledger_changed', u, 'this address served ledger ' + ledger_id +
|
|
195
|
+
' before and now serves ' + other.id, ledger_id)
|
|
196
|
+
else:
|
|
197
|
+
self.problem('warning', 'receipt_url_mismatch', url, 'receipts from ledger ' + ledger_id +
|
|
198
|
+
' name ' + u + ', which serves ' + other.id, ledger_id)
|
|
199
|
+
continue
|
|
200
|
+
self.ledger(other, u)
|
|
201
|
+
copies = list(self.copies.get(ledger_id, []))
|
|
202
|
+
seen = self.store.seen(ledger_id)
|
|
203
|
+
if seen is not None:
|
|
204
|
+
copies.append(('last seen copy', Ledger(seen, cache=False)))
|
|
205
|
+
if not copies:
|
|
206
|
+
self.problem('warning', 'receipts_unchecked', url, 'could not reach ledger ' + ledger_id)
|
|
207
|
+
continue
|
|
208
|
+
reported = set()
|
|
209
|
+
for r in g['receipts']:
|
|
210
|
+
e, s = r['entry'], r['entry']['seq']
|
|
211
|
+
found = False
|
|
212
|
+
for c_url, c in copies:
|
|
213
|
+
if s >= len(c.ids):
|
|
214
|
+
continue
|
|
215
|
+
found = True
|
|
216
|
+
# every entry the receipt holds (its key chain too) is compared: a chain entry signed by the owner
|
|
217
|
+
# that differs from this copy proves a rewrite as surely as the receipt entry itself
|
|
218
|
+
for x in r.get('keys', []) + [e]:
|
|
219
|
+
xs = x['seq']
|
|
220
|
+
if xs >= len(c.ids) or c.ids[xs] == hash_(x) or (c_url, xs) in reported:
|
|
221
|
+
continue
|
|
222
|
+
reported.add((c_url, xs))
|
|
223
|
+
if not self.signed_then(c, x):
|
|
224
|
+
# signed by a key this ledger did not have at that seq (a revoked device, a forged
|
|
225
|
+
# chain): not the owner's word, so not proof against the ledger
|
|
226
|
+
self.problem('warning', 'receipt_bad_signer', url, 'the receipt\'s entry ' + str(xs) +
|
|
227
|
+
' of ledger ' + ledger_id + ' is not signed by a key that ledger had then')
|
|
228
|
+
continue
|
|
229
|
+
proof = self.store.proof('receipt-' + ledger_id[7:19] + '-' + str(xs), {
|
|
230
|
+
'kind': 'receipt_mismatch', 'ledger': ledger_id, 'url': c_url, 'receipt': r,
|
|
231
|
+
'entry': x, 'now': json.loads(c.lines[xs]),
|
|
232
|
+
'note': 'The receipt holds an entry at this seq signed by a key the ledger had then; this '
|
|
233
|
+
'copy has another.'})
|
|
234
|
+
self.problem('error', 'receipt_mismatch', c_url, 'entry ' + str(xs) + ' differs from the '
|
|
235
|
+
'receipt held by ' + url, ledger_id, proof)
|
|
236
|
+
if not found:
|
|
237
|
+
self.problem('warning', 'receipt_not_visible', url, 'no copy of ledger ' + ledger_id +
|
|
238
|
+
' has entry ' + str(s) + ' yet')
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def signed_then(c, e):
|
|
242
|
+
"""True if entry e is signed by a key ledger copy c had at e's seq (its full key history, not a receipt's)."""
|
|
243
|
+
keys = Keys(c.header['owner'])
|
|
244
|
+
for x in c.entries[:e['seq']]:
|
|
245
|
+
if x['msg']['type'] in KEY_TYPES:
|
|
246
|
+
keys.apply(x['msg'])
|
|
247
|
+
return keys.signed_by(e) is not None
|
|
248
|
+
|
|
249
|
+
def published(self, dex):
|
|
250
|
+
"""For a dex folder with "publish" in config.json: the published ledger must be the start of the local one."""
|
|
251
|
+
cfg = os.path.join(dex, 'config.json')
|
|
252
|
+
if not os.path.exists(cfg):
|
|
253
|
+
return
|
|
254
|
+
with open(cfg, encoding='utf-8') as f:
|
|
255
|
+
if not isinstance(json.load(f).get('publish'), dict):
|
|
256
|
+
return
|
|
257
|
+
from dexweb import dexweb
|
|
258
|
+
from .render import dexweb_utf8
|
|
259
|
+
dexweb_utf8()
|
|
260
|
+
with inside(os.path.abspath(dex)):
|
|
261
|
+
pub = dexweb.Dexweb().published(LEDGER)
|
|
262
|
+
if pub is None:
|
|
263
|
+
return
|
|
264
|
+
with open(os.path.join(dex, LEDGER), 'rb') as f:
|
|
265
|
+
local = f.read()
|
|
266
|
+
pub = pub.encode('utf-8')
|
|
267
|
+
if local.startswith(pub):
|
|
268
|
+
return
|
|
269
|
+
if pub.startswith(local):
|
|
270
|
+
self.problem('warning', 'behind', dex, 'the published ledger has entries this copy does not: publish to '
|
|
271
|
+
'catch up')
|
|
272
|
+
else:
|
|
273
|
+
self.problem('error', 'published_differs', dex, 'the published ledger is not the start of this one')
|
|
274
|
+
|
|
275
|
+
def run(self, sources, published=False):
|
|
276
|
+
loaded = []
|
|
277
|
+
for src in sources:
|
|
278
|
+
led = self.load(src)
|
|
279
|
+
if led is None:
|
|
280
|
+
continue
|
|
281
|
+
url = src if re.match(r'https?://', src) else os.path.abspath(src)
|
|
282
|
+
self.ledger(led, url)
|
|
283
|
+
loaded.append((led, url))
|
|
284
|
+
if published and os.path.isdir(src):
|
|
285
|
+
self.published(src)
|
|
286
|
+
for led, url in loaded: # after every source is in, so receipts meet every copy
|
|
287
|
+
if led.header is not None:
|
|
288
|
+
self.receipts(led, url)
|
|
289
|
+
self.store.save()
|
|
290
|
+
errors = [p for p in self.problems if p['level'] == 'error']
|
|
291
|
+
return {'time': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
|
292
|
+
'ok': not errors, 'errors': len(errors), 'warnings': len(self.problems) - len(errors),
|
|
293
|
+
'checked': self.checked, 'problems': self.problems}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def check(sources, state_dir=STATE, media=False, published=False, log=print, root=None):
|
|
297
|
+
return Checker(state_dir, media, log, root).run(sources, published)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
WORKFLOW = '''# ledgdex check: made by "ledgdex workflow". Runs on every push, daily, and by hand.
|
|
301
|
+
# A failed run means a ledger was tampered with or rewritten; the report and proofs are attached to the run.
|
|
302
|
+
name: ledgdex check
|
|
303
|
+
on:
|
|
304
|
+
push:
|
|
305
|
+
schedule:
|
|
306
|
+
- cron: "{cron}"
|
|
307
|
+
workflow_dispatch:
|
|
308
|
+
permissions:
|
|
309
|
+
contents: read
|
|
310
|
+
jobs:
|
|
311
|
+
check:
|
|
312
|
+
runs-on: ubuntu-latest
|
|
313
|
+
steps:
|
|
314
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
315
|
+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
316
|
+
with:
|
|
317
|
+
python-version: "3.12"
|
|
318
|
+
- run: pip install "ledgdex=={version}"
|
|
319
|
+
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
320
|
+
with:
|
|
321
|
+
path: .ledgdex-check
|
|
322
|
+
key: ledgdex-check-${{{{ github.run_id }}}}
|
|
323
|
+
restore-keys: ledgdex-check-
|
|
324
|
+
- run: ledgdex check {sources} --json ledgdex-check.json{media}
|
|
325
|
+
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
326
|
+
if: always()
|
|
327
|
+
with:
|
|
328
|
+
path: .ledgdex-check
|
|
329
|
+
key: ledgdex-check-${{{{ github.run_id }}}}
|
|
330
|
+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
331
|
+
if: always()
|
|
332
|
+
with:
|
|
333
|
+
name: ledgdex-check
|
|
334
|
+
path: |
|
|
335
|
+
ledgdex-check.json
|
|
336
|
+
.ledgdex-check/proofs/
|
|
337
|
+
if-no-files-found: ignore
|
|
338
|
+
'''
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def workflow(sources, cron='17 6 * * *', media=False, version=None):
|
|
342
|
+
"""The workflow file. Sources are shell-quoted and the cron is checked, so neither can add steps; ledgdex is
|
|
343
|
+
installed from PyPI at this exact version, not from a moving branch."""
|
|
344
|
+
import shlex
|
|
345
|
+
from . import __version__
|
|
346
|
+
if not re.match(r'[0-9*/,-]+( [0-9*/,A-Za-z-]+){4}\Z', cron):
|
|
347
|
+
raise Invalid('not a cron schedule: ' + cron)
|
|
348
|
+
for src in sources:
|
|
349
|
+
if not src or src.startswith('-') or any(c in src for c in '\n\r'):
|
|
350
|
+
raise Invalid('not a ledger source: ' + repr(src))
|
|
351
|
+
return WORKFLOW.format(cron=cron, sources=' '.join(shlex.quote(x) for x in sources), media=' --media' if media else '',
|
|
352
|
+
version=version or __version__)
|