codeimprint 0.5.0a11__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.
- codeimprint-0.5.0a11.dist-info/METADATA +92 -0
- codeimprint-0.5.0a11.dist-info/RECORD +23 -0
- codeimprint-0.5.0a11.dist-info/WHEEL +4 -0
- codeimprint-0.5.0a11.dist-info/entry_points.txt +2 -0
- codeimprint-0.5.0a11.dist-info/licenses/LICENSE +177 -0
- codeimprint-0.5.0a11.dist-info/licenses/NOTICE +11 -0
- codeimprint_cli/__init__.py +7 -0
- codeimprint_cli/__main__.py +5 -0
- codeimprint_cli/api.py +315 -0
- codeimprint_cli/chainseal.py +175 -0
- codeimprint_cli/cli.py +2128 -0
- codeimprint_cli/config.py +181 -0
- codeimprint_cli/gitrepo.py +148 -0
- codeimprint_cli/imprint_file.py +128 -0
- codeimprint_cli/linecount.py +340 -0
- codeimprint_cli/ownership.py +341 -0
- codeimprint_cli/project.py +129 -0
- codeimprint_cli/receipt.py +679 -0
- codeimprint_cli/records.py +59 -0
- codeimprint_cli/timestamp.py +187 -0
- codeimprint_cli/transparency.py +71 -0
- codeimprint_cli/trust.py +190 -0
- codeimprint_cli/tsa_roots.py +137 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Ownership claims (ADR 0002, D4): build claim documents and verify a project's ownership
|
|
2
|
+
listing.
|
|
3
|
+
|
|
4
|
+
Verification fetches every record the listing relies on and checks each receipt (format 4)
|
|
5
|
+
like any seal: digest, typed ID, owner signatures, attestation, log inclusion and timestamps.
|
|
6
|
+
It then checks the claim rules (every listed key signed; the claimant's identity was valid at
|
|
7
|
+
level email or higher, or an organisation's disclosed domain identity) and **recomputes which
|
|
8
|
+
seals each claim covers** from the seals' own receipts (their chain signer and time), the claim
|
|
9
|
+
keys, membership windows, assignments and contributor agreements. The result must equal the
|
|
10
|
+
server's listing.
|
|
11
|
+
|
|
12
|
+
What this cannot show: that the server listed every record (a record left out of the listing
|
|
13
|
+
is not seen here; the transparency log is where it would show), that the server checked the
|
|
14
|
+
e-mail, GitHub or DNS evidence correctly, organisation key windows (taken from the
|
|
15
|
+
organisation's public page), or whether a claim is legally valid.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import secrets
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from . import receipt, trust
|
|
25
|
+
from .api import ApiError, NetworkError
|
|
26
|
+
from .imprint_file import canonical
|
|
27
|
+
from .receipt import FAIL, PASS, WARN, Check
|
|
28
|
+
|
|
29
|
+
CLAIM = "codeimprint.claim.v1"
|
|
30
|
+
CLAIM_KEY = "codeimprint.claim-key.v1"
|
|
31
|
+
ASSIGNMENT = "codeimprint.assignment.v1"
|
|
32
|
+
CONTRIBUTOR = "codeimprint.contributor.v1"
|
|
33
|
+
REVOCATION = "codeimprint.revocation.v1"
|
|
34
|
+
MEMBER = "codeimprint.org-member.v1"
|
|
35
|
+
MEMBER_END = "codeimprint.org-member-end.v1"
|
|
36
|
+
IDENTITY = "codeimprint.identity.v1"
|
|
37
|
+
ASSIGNING = ("cla", "employment", "contract", "assignment")
|
|
38
|
+
PERSON_LEVELS = ("email", "vcs")
|
|
39
|
+
DISCLAIMER = ("An ownership claim records what the claimant states and the evidence they "
|
|
40
|
+
"committed. It is not a legal determination of ownership.")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# -- documents ---------------------------------------------------------------------------
|
|
44
|
+
def document_commitment(path: Path) -> dict:
|
|
45
|
+
"""A salted commitment to a supporting document. Only the commitment is sent; keep the
|
|
46
|
+
salt (and the file) to reveal it later."""
|
|
47
|
+
file_hash = hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
48
|
+
salt = trust.b64e(secrets.token_bytes(32))
|
|
49
|
+
commitment = hashlib.sha256(canonical({"salt": salt, "sha256": file_hash})).hexdigest()
|
|
50
|
+
return {"commitment": commitment, "salt": salt, "sha256": file_hash, "file": str(path)}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check_commitment(path: Path, salt: str, commitment: str) -> bool:
|
|
54
|
+
file_hash = hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
55
|
+
return hashlib.sha256(canonical({"salt": salt, "sha256": file_hash})).hexdigest() == \
|
|
56
|
+
commitment
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def digest(document: dict) -> str:
|
|
60
|
+
return hashlib.sha256(canonical(document)).hexdigest()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# -- coverage ----------------------------------------------------------------------------
|
|
64
|
+
def within(window: dict, at: str) -> bool:
|
|
65
|
+
return ((window.get("valid_from") is None or window["valid_from"] <= at)
|
|
66
|
+
and (window.get("valid_until") is None or at < window["valid_until"]))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def coverage(seals: list[dict], claims: list[dict]) -> dict[str, set[tuple[str, str]]]:
|
|
70
|
+
"""``{seal: {(claim, reason)}}`` from seals ({seal, signer, attested_at, suspect}) and
|
|
71
|
+
claims ({id, windows, end}). The rule (D4): the seal's chain signer was an authorised key
|
|
72
|
+
of the claim at the seal's time, the claim had not ended, and the key was not reported
|
|
73
|
+
compromised by then."""
|
|
74
|
+
out: dict[str, set[tuple[str, str]]] = {}
|
|
75
|
+
for s in seals:
|
|
76
|
+
hits = set()
|
|
77
|
+
if s["signer"] and not s["suspect"]:
|
|
78
|
+
for c in claims:
|
|
79
|
+
if c["end"] and s["attested_at"] >= c["end"]:
|
|
80
|
+
continue
|
|
81
|
+
why = next((w for w in c["windows"] if w["fingerprint"] == s["signer"]
|
|
82
|
+
and within(w, s["attested_at"])), None)
|
|
83
|
+
if why:
|
|
84
|
+
hits.add((c["id"], why["reason"]))
|
|
85
|
+
out[s["seal"]] = hits
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _Verifier:
|
|
90
|
+
def __init__(self, client, keys: list[dict], anchors=None):
|
|
91
|
+
self.client, self.keys, self.anchors = client, keys, anchors
|
|
92
|
+
self.cache: dict[str, dict] = {}
|
|
93
|
+
self.checks: list[Check] = []
|
|
94
|
+
|
|
95
|
+
def fail(self, name: str, detail: str) -> None:
|
|
96
|
+
self.checks.append(Check(name, FAIL, detail))
|
|
97
|
+
|
|
98
|
+
def record(self, rid: str) -> dict | None:
|
|
99
|
+
"""A verified record receipt (fetched once), or None after a FAIL."""
|
|
100
|
+
if rid in self.cache:
|
|
101
|
+
return self.cache[rid]
|
|
102
|
+
try:
|
|
103
|
+
rec = self.client.public_record(rid)
|
|
104
|
+
except (ApiError, NetworkError) as exc:
|
|
105
|
+
self.fail(rid, f"cannot fetch: {exc}")
|
|
106
|
+
self.cache[rid] = None
|
|
107
|
+
return None
|
|
108
|
+
bad = [c for c in receipt.verify(rec, self.keys, anchors=self.anchors)
|
|
109
|
+
if c.status == FAIL]
|
|
110
|
+
if bad:
|
|
111
|
+
self.fail(rid, "; ".join(f"{c.name}: {c.detail}" for c in bad))
|
|
112
|
+
rec = None
|
|
113
|
+
self.cache[rid] = rec
|
|
114
|
+
return rec
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _at(rec: dict) -> str:
|
|
118
|
+
return rec["attestation"]["statement"]["attested_at"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _sigs(rec: dict) -> set[tuple[str, str]]:
|
|
122
|
+
return {(s["role"], s["fingerprint"]) for s in rec.get("signatures") or []}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def verify(client, project: str, keys: list[dict], anchors=None) -> tuple[dict, list[Check]]:
|
|
126
|
+
"""Fetch the ownership listing (every page) and verify it. Returns the listing and the
|
|
127
|
+
checks; any FAIL means the listing cannot be relied on."""
|
|
128
|
+
view, after = None, None
|
|
129
|
+
while True:
|
|
130
|
+
page = client.public_ownership(project, after)
|
|
131
|
+
if view is None:
|
|
132
|
+
view = page
|
|
133
|
+
else:
|
|
134
|
+
view["seals"] += page["seals"]
|
|
135
|
+
if page.get("next") is None:
|
|
136
|
+
break
|
|
137
|
+
after = page["next"]
|
|
138
|
+
v = _Verifier(client, keys, anchors)
|
|
139
|
+
|
|
140
|
+
# Seals: the signer and time come from each seal's own verified receipt.
|
|
141
|
+
seals, status_cache = [], {}
|
|
142
|
+
for s in view["seals"]:
|
|
143
|
+
try:
|
|
144
|
+
rec = client.public_imprint(s["seal"])
|
|
145
|
+
except (ApiError, NetworkError) as exc:
|
|
146
|
+
v.fail(f"seal {s['seal']}", f"cannot fetch: {exc}")
|
|
147
|
+
continue
|
|
148
|
+
bad = [c for c in receipt.verify(rec, keys, anchors=anchors) if c.status == FAIL]
|
|
149
|
+
signer = receipt.chain_signer(rec)
|
|
150
|
+
if bad:
|
|
151
|
+
v.fail(f"seal {s['seal']}", "; ".join(f"{c.name}: {c.detail}" for c in bad))
|
|
152
|
+
continue
|
|
153
|
+
if (signer, _at(rec)) != (s["signer"], s["attested_at"]):
|
|
154
|
+
v.fail(f"seal {s['seal']}", "the listing misstates the seal's signer or time")
|
|
155
|
+
continue
|
|
156
|
+
if signer not in status_cache:
|
|
157
|
+
status_cache[signer] = client.key_identity(signer).get("compromised_since")
|
|
158
|
+
since = status_cache[signer]
|
|
159
|
+
seals.append({"seal": s["seal"], "signer": signer, "attested_at": _at(rec),
|
|
160
|
+
"suspect": bool(since and _at(rec) >= since)})
|
|
161
|
+
|
|
162
|
+
claims = []
|
|
163
|
+
for c in view["claims"]:
|
|
164
|
+
rules = _claim(v, c, project)
|
|
165
|
+
if rules:
|
|
166
|
+
claims.append(rules)
|
|
167
|
+
if any(ch.status == FAIL for ch in v.checks):
|
|
168
|
+
return view, v.checks
|
|
169
|
+
mine = coverage(seals, claims)
|
|
170
|
+
theirs = {s["seal"]: {(x["claim"], x["reason"]) for x in s["covered_by"]}
|
|
171
|
+
for s in view["seals"]}
|
|
172
|
+
wrong = [s for s in mine if mine[s] != theirs.get(s)]
|
|
173
|
+
if wrong:
|
|
174
|
+
v.fail("coverage", f"{len(wrong)} seal(s) are not covered as the server lists them "
|
|
175
|
+
f"(first: {wrong[0]})")
|
|
176
|
+
else:
|
|
177
|
+
hit = sum(1 for s in mine.values() if s)
|
|
178
|
+
v.checks.append(Check("coverage", PASS, f"{len(mine)} seal(s), {hit} covered by a "
|
|
179
|
+
"claim; recomputed here and equal to the "
|
|
180
|
+
"server's listing"))
|
|
181
|
+
if view.get("disclaimer") != DISCLAIMER:
|
|
182
|
+
v.checks.append(Check("wording", WARN, "the listing does not carry the ownership "
|
|
183
|
+
"disclaimer"))
|
|
184
|
+
return view, v.checks
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _claim(v: _Verifier, c: dict, project: str) -> dict | None:
|
|
188
|
+
"""Check one claim and the records behind it; returns its coverage rules."""
|
|
189
|
+
name = f"claim {c['id']}"
|
|
190
|
+
rec = v.record(c["id"])
|
|
191
|
+
if rec is None:
|
|
192
|
+
return None
|
|
193
|
+
doc, at = rec["record"]["document"], _at(rec)
|
|
194
|
+
if rec["record"]["type"] != CLAIM or doc.get("project") != project:
|
|
195
|
+
v.fail(name, "not a claim on this project")
|
|
196
|
+
return None
|
|
197
|
+
if (doc["basis"], doc["effective_at"], doc["share"], doc["keys"]) != (
|
|
198
|
+
c["basis"], c["effective_at"], c["share"],
|
|
199
|
+
sorted({w["fingerprint"] for w in c["authorised"] if w["reason"] == "claim-key"
|
|
200
|
+
and w["record"] == c["id"]}) if doc["claimant"]["kind"] == "person"
|
|
201
|
+
else doc["keys"]):
|
|
202
|
+
v.fail(name, "the listing misstates the claim")
|
|
203
|
+
return None
|
|
204
|
+
claimant = doc["claimant"]
|
|
205
|
+
idt = v.record(claimant["identity"])
|
|
206
|
+
if idt is None:
|
|
207
|
+
return None
|
|
208
|
+
idoc = idt["record"]["document"]
|
|
209
|
+
if idt["record"]["type"] != IDENTITY or not (idoc["valid_from"] <= at < idoc["valid_until"]):
|
|
210
|
+
v.fail(name, "the claimant's identity record was not valid when the claim was made")
|
|
211
|
+
return None
|
|
212
|
+
status = v.client.key_identity(idoc["key"])
|
|
213
|
+
revoked = {r["id"]: r for r in status.get("revocations") or []}
|
|
214
|
+
entry = next((i for i in status.get("identities") or [] if i["id"] == idt["imprint_id"]),
|
|
215
|
+
None)
|
|
216
|
+
if entry and entry.get("revoked_by") and revoked.get(entry["revoked_by"], {}).get(
|
|
217
|
+
"effective_at", "9999") <= at:
|
|
218
|
+
v.fail(name, "the claimant's identity was revoked before the claim")
|
|
219
|
+
return None
|
|
220
|
+
expected = {("claimant", idoc["key"])} | {("authorised", k) for k in doc["keys"]
|
|
221
|
+
if k != idoc["key"]}
|
|
222
|
+
if _sigs(rec) != expected:
|
|
223
|
+
v.fail(name, "not signed by exactly the claimant and every listed key (consent)")
|
|
224
|
+
return None
|
|
225
|
+
windows = []
|
|
226
|
+
if claimant["kind"] == "person":
|
|
227
|
+
if idoc["level"] not in PERSON_LEVELS or idoc["key"] not in doc["keys"]:
|
|
228
|
+
v.fail(name, "a person's claim needs an identity at level email or higher on one "
|
|
229
|
+
"of its keys")
|
|
230
|
+
return None
|
|
231
|
+
windows += [{"fingerprint": k, "valid_from": None, "valid_until": None,
|
|
232
|
+
"reason": "claim-key"} for k in doc["keys"]]
|
|
233
|
+
else:
|
|
234
|
+
org = v.client.public_org(claimant["org"])
|
|
235
|
+
if idoc["level"] != "domain" or idoc.get("org") != claimant["org"]:
|
|
236
|
+
v.fail(name, "an organisation claims with its own domain identity")
|
|
237
|
+
return None
|
|
238
|
+
dom = next((d for d in org["domains"] if d["identity"] == idt["imprint_id"]
|
|
239
|
+
and d.get("disclosure")), None)
|
|
240
|
+
dsc = v.record(dom["disclosure"]) if dom else None
|
|
241
|
+
if dsc is None:
|
|
242
|
+
v.fail(name, "the organisation's domain was not disclosed (D9)")
|
|
243
|
+
return None
|
|
244
|
+
ddoc = dsc["record"]["document"]
|
|
245
|
+
if hashlib.sha256(canonical({"salt": ddoc["salt"], "value": ddoc["value"]})) \
|
|
246
|
+
.hexdigest() != idoc["subject_commitment"] or ddoc["record"] != idt[
|
|
247
|
+
"imprint_id"] or _at(dsc) > at:
|
|
248
|
+
v.fail(name, "the disclosure does not open the identity's commitment, or came "
|
|
249
|
+
"after the claim")
|
|
250
|
+
return None
|
|
251
|
+
active = sorted(k["fingerprint"] for k in org["keys"] if within(k, at))
|
|
252
|
+
if doc["keys"] != active:
|
|
253
|
+
v.fail(name, "the claim does not list exactly the organisation's keys at that time")
|
|
254
|
+
return None
|
|
255
|
+
windows += [{"fingerprint": k["fingerprint"], "valid_from": k["valid_from"],
|
|
256
|
+
"valid_until": k["valid_until"], "reason": "org-key"} for k in org["keys"]]
|
|
257
|
+
if doc["basis"] == "employer":
|
|
258
|
+
for m in org["members"]:
|
|
259
|
+
if m.get("revoked_by"):
|
|
260
|
+
continue
|
|
261
|
+
mrec = v.record(m["membership"])
|
|
262
|
+
if mrec is None:
|
|
263
|
+
return None
|
|
264
|
+
mdoc = mrec["record"]["document"]
|
|
265
|
+
until = None
|
|
266
|
+
if m.get("ended_by"):
|
|
267
|
+
end = v.record(m["ended_by"])
|
|
268
|
+
if end is None:
|
|
269
|
+
return None
|
|
270
|
+
edoc = end["record"]["document"]
|
|
271
|
+
if edoc.get("membership") != m["membership"]:
|
|
272
|
+
v.fail(name, "a membership end record is for another membership")
|
|
273
|
+
return None
|
|
274
|
+
until = edoc["valid_until"]
|
|
275
|
+
if (mdoc["org"], mdoc["member_key"], mdoc["valid_from"], until) != (
|
|
276
|
+
claimant["org"], m["member_key"], m["valid_from"], m["valid_until"]):
|
|
277
|
+
v.fail(name, "the organisation page misstates a membership window")
|
|
278
|
+
return None
|
|
279
|
+
windows.append({"fingerprint": mdoc["member_key"], "valid_from":
|
|
280
|
+
mdoc["valid_from"], "valid_until": until,
|
|
281
|
+
"reason": "membership"})
|
|
282
|
+
end = None
|
|
283
|
+
for rid in c["records"]:
|
|
284
|
+
r = v.record(rid)
|
|
285
|
+
if r is None:
|
|
286
|
+
return None
|
|
287
|
+
rtype, d = r["record"]["type"], r["record"]["document"]
|
|
288
|
+
if rtype == CLAIM_KEY and d.get("claim") == c["id"]:
|
|
289
|
+
by = [fp for role, fp in _sigs(r) if role == "authorised"]
|
|
290
|
+
if not by or not any(w["fingerprint"] == by[0] and w["reason"] == "claim-key"
|
|
291
|
+
and within(w, _at(r)) for w in windows):
|
|
292
|
+
v.fail(name, f"{rid} is not signed by a key authorised at that time")
|
|
293
|
+
return None
|
|
294
|
+
if d["action"] == "add":
|
|
295
|
+
if ("key", d["key"]) not in _sigs(r):
|
|
296
|
+
v.fail(name, f"{rid}: the added key did not consent")
|
|
297
|
+
return None
|
|
298
|
+
if not any(w["fingerprint"] == d["key"] for w in windows):
|
|
299
|
+
windows.append({"fingerprint": d["key"], "valid_from": _at(r),
|
|
300
|
+
"valid_until": None, "reason": "claim-key"})
|
|
301
|
+
else:
|
|
302
|
+
for w in windows:
|
|
303
|
+
if w["fingerprint"] == d["key"] and w["reason"] == "claim-key" and \
|
|
304
|
+
w["valid_until"] is None:
|
|
305
|
+
w["valid_until"] = _at(r)
|
|
306
|
+
elif rtype == REVOCATION and d.get("revokes") == c["id"]:
|
|
307
|
+
holder = [fp for role, fp in _sigs(r) if role == "holder"]
|
|
308
|
+
if holder and holder != [d["key"]]:
|
|
309
|
+
v.fail(name, f"{rid} is signed by a key other than the one it names")
|
|
310
|
+
return None
|
|
311
|
+
end = min(end or d["effective_at"], d["effective_at"])
|
|
312
|
+
elif rtype == ASSIGNMENT and d.get("to_claim") == c["id"]:
|
|
313
|
+
if ("assignor", d["from_key"]) not in _sigs(r):
|
|
314
|
+
v.fail(name, f"{rid}: the contributor did not sign the assignment")
|
|
315
|
+
return None
|
|
316
|
+
windows.append({"fingerprint": d["from_key"], "valid_from": d["from"],
|
|
317
|
+
"valid_until": d["until"], "reason": "assignment"})
|
|
318
|
+
elif rtype == CONTRIBUTOR and (d.get("agreement") or {}).get("assigns_to") == c["id"]:
|
|
319
|
+
ag = d["agreement"]
|
|
320
|
+
if ("contributor", d["contributor_key"]) not in _sigs(r) or \
|
|
321
|
+
ag["kind"] not in ASSIGNING:
|
|
322
|
+
v.fail(name, f"{rid}: not an assigning agreement signed by the contributor")
|
|
323
|
+
return None
|
|
324
|
+
windows.append({"fingerprint": d["contributor_key"], "valid_from": ag["from"],
|
|
325
|
+
"valid_until": ag.get("until"), "reason": "contributor-agreement"})
|
|
326
|
+
elif rtype not in (MEMBER, MEMBER_END):
|
|
327
|
+
v.fail(name, f"{rid} is listed but does not belong to this claim")
|
|
328
|
+
return None
|
|
329
|
+
if (end is None) != (c["status"] == "active") or (end and end != c["ended_at"]):
|
|
330
|
+
v.fail(name, "the listing misstates whether or when the claim ended")
|
|
331
|
+
return None
|
|
332
|
+
level = f"level {idoc['level']}" + (f", DNSSEC {idoc['dnssec']}" if idoc.get("dnssec")
|
|
333
|
+
else "")
|
|
334
|
+
v.checks.append(Check(name, PASS, f"{c['label']} ({doc['basis']}, {level}); signed by "
|
|
335
|
+
f"{len(expected)} key(s); "
|
|
336
|
+
+ ("active" if end is None else f"ended {end}")))
|
|
337
|
+
return {"id": c["id"], "windows": windows, "end": end}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def fingerprint_of(key) -> str:
|
|
341
|
+
return trust.fingerprint(trust.public_raw(key))
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Choosing which files leave the machine, and packing them into a zip.
|
|
2
|
+
|
|
3
|
+
Only source files the server reads are sent (Python in this release). Files ignored by
|
|
4
|
+
.gitignore (root and nested) or excluded in .imprint/config.json are never sent, nor are
|
|
5
|
+
symlinks, version-control folders and virtual environments.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import zipfile
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import pathspec
|
|
19
|
+
|
|
20
|
+
CONFIG_DIR = ".imprint"
|
|
21
|
+
CONFIG_FILE = "config.json"
|
|
22
|
+
SOURCE_SUFFIXES = (".py",)
|
|
23
|
+
SKIP_DIRS = {
|
|
24
|
+
".git", ".hg", ".svn", ".imprint", "__pycache__", ".venv", "venv", "env", "node_modules",
|
|
25
|
+
"build", "dist", "site-packages", ".tox", ".mypy_cache", ".pytest_cache", ".ruff_cache",
|
|
26
|
+
}
|
|
27
|
+
DEFAULT_EXCLUDE = [
|
|
28
|
+
"vendor/*", "third_party/*", "node_modules/*", "*/site-packages/*", "*/migrations/*",
|
|
29
|
+
"build/*", "dist/*",
|
|
30
|
+
]
|
|
31
|
+
_ZIP_TIME = (1980, 1, 1, 0, 0, 0) # fixed, so the same files give the same zip
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_config(root: Path) -> dict:
|
|
35
|
+
path = root / CONFIG_DIR / CONFIG_FILE
|
|
36
|
+
if not path.is_file():
|
|
37
|
+
return {}
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
40
|
+
except ValueError as exc:
|
|
41
|
+
raise ValueError(f"{path} is not valid JSON: {exc}") from exc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _excluded(rel: str, patterns: list[str]) -> bool:
|
|
45
|
+
"""Glob on the project-relative POSIX path; ``*`` also crosses folders (``vendor/*``)."""
|
|
46
|
+
return any(fnmatch.fnmatchcase(rel, p) for p in patterns)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Selection:
|
|
51
|
+
root: Path
|
|
52
|
+
files: list[str] = field(default_factory=list) # project-relative POSIX paths
|
|
53
|
+
total_bytes: int = 0
|
|
54
|
+
ignored: int = 0
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def count(self) -> int:
|
|
58
|
+
return len(self.files)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def select_files(root: str | Path, suffixes: tuple[str, ...] | None = SOURCE_SUFFIXES,
|
|
62
|
+
use_excludes: bool = True) -> Selection:
|
|
63
|
+
"""The files to send: ``suffixes`` (None: every file, for the line count) that
|
|
64
|
+
.gitignore and (``use_excludes``) .imprint/config.json do not leave out."""
|
|
65
|
+
root = Path(root).resolve()
|
|
66
|
+
if not root.is_dir():
|
|
67
|
+
raise FileNotFoundError(f"no such folder: {root}")
|
|
68
|
+
excludes = list(load_config(root).get("exclude", [])) if use_excludes else []
|
|
69
|
+
ignores: list[tuple[str, pathspec.GitIgnoreSpec]] = [] # (folder prefix, its .gitignore)
|
|
70
|
+
sel = Selection(root=root)
|
|
71
|
+
|
|
72
|
+
def ignored(rel: str, is_dir: bool) -> bool:
|
|
73
|
+
for prefix, spec in ignores:
|
|
74
|
+
if prefix and not rel.startswith(prefix):
|
|
75
|
+
continue
|
|
76
|
+
sub = rel[len(prefix):] + ("/" if is_dir else "")
|
|
77
|
+
if spec.match_file(sub):
|
|
78
|
+
return True
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
82
|
+
here = Path(dirpath)
|
|
83
|
+
rel_dir = here.relative_to(root).as_posix()
|
|
84
|
+
prefix = "" if rel_dir == "." else rel_dir + "/"
|
|
85
|
+
gitignore = here / ".gitignore"
|
|
86
|
+
if gitignore.is_file():
|
|
87
|
+
lines = gitignore.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
88
|
+
ignores.append((prefix, pathspec.GitIgnoreSpec.from_lines(lines)))
|
|
89
|
+
keep = []
|
|
90
|
+
for d in sorted(dirnames):
|
|
91
|
+
rel = prefix + d
|
|
92
|
+
if d in SKIP_DIRS or (here / d).is_symlink() or ignored(rel, True) \
|
|
93
|
+
or _excluded(rel + "/", excludes):
|
|
94
|
+
sel.ignored += 1
|
|
95
|
+
continue
|
|
96
|
+
keep.append(d)
|
|
97
|
+
dirnames[:] = keep
|
|
98
|
+
for name in sorted(filenames):
|
|
99
|
+
rel = prefix + name
|
|
100
|
+
path = here / name
|
|
101
|
+
if (suffixes is not None and not name.endswith(suffixes)) or path.is_symlink():
|
|
102
|
+
continue
|
|
103
|
+
if ignored(rel, False) or _excluded(rel, excludes):
|
|
104
|
+
sel.ignored += 1
|
|
105
|
+
continue
|
|
106
|
+
sel.files.append(rel)
|
|
107
|
+
sel.total_bytes += path.stat().st_size
|
|
108
|
+
sel.files.sort()
|
|
109
|
+
return sel
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_zip(sel: Selection) -> bytes:
|
|
113
|
+
buf = io.BytesIO()
|
|
114
|
+
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
115
|
+
for rel in sel.files:
|
|
116
|
+
info = zipfile.ZipInfo(rel, date_time=_ZIP_TIME)
|
|
117
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
118
|
+
info.external_attr = 0o644 << 16
|
|
119
|
+
zf.writestr(info, (sel.root / rel).read_bytes())
|
|
120
|
+
return buf.getvalue()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def human_size(n: int) -> str:
|
|
124
|
+
size = float(n)
|
|
125
|
+
for unit in ("bytes", "kB", "MB", "GB"):
|
|
126
|
+
if size < 1000 or unit == "GB":
|
|
127
|
+
return f"{int(size)} {unit}" if unit == "bytes" else f"{size:.1f} {unit}"
|
|
128
|
+
size /= 1000
|
|
129
|
+
return f"{n} bytes"
|