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,175 @@
|
|
|
1
|
+
"""Incremental Git seals, the project chain and offline-safe hooks (client side).
|
|
2
|
+
|
|
3
|
+
The server's protocol (see docs/how-sealing-works.md): send the commit's file list (paths and
|
|
4
|
+
Git blob IDs), upload only the blobs the server asks for, let it prepare the seal, sign the
|
|
5
|
+
statement it returns with your developer key, and finalise. Nothing here looks inside the
|
|
6
|
+
code: blobs are uploaded as they are and fingerprinted by the server.
|
|
7
|
+
|
|
8
|
+
Local state lives in the Git directory, never in the working tree:
|
|
9
|
+
.git/codeimprint/chain.jsonl the seals made from this clone (with their commits)
|
|
10
|
+
.git/codeimprint/queue commits a hook could not seal yet (offline or an error)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import gitrepo, linecount, trust
|
|
20
|
+
from .imprint_file import canonical
|
|
21
|
+
|
|
22
|
+
PAGE = 5000 # files per plan request
|
|
23
|
+
BATCH_BYTES = 40 * 1024 * 1024 # blobs per upload request (the server takes 50 MB)
|
|
24
|
+
JOB_WAIT_SECONDS = 1800
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def sign(key, statement: dict) -> str:
|
|
28
|
+
return trust.b64e(key.sign(canonical(statement)))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def public_key(key) -> str:
|
|
32
|
+
return trust.b64e(trust.public_raw(key))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_project(client, key, name: str | None = None) -> dict:
|
|
36
|
+
"""Create a project for this developer key and finalise its genesis seal (an empty tree).
|
|
37
|
+
``name`` is the project's display name (shown on its public page)."""
|
|
38
|
+
pub = public_key(key)
|
|
39
|
+
made = client.create_project(pub, sign(key, {"type": "codeimprint.project-create.v1",
|
|
40
|
+
"public_key": pub}), name)
|
|
41
|
+
g = made["genesis"]
|
|
42
|
+
genesis = client.finalise(made["project_id"], g["pending_id"], pub,
|
|
43
|
+
sign(key, g["statement"]))
|
|
44
|
+
return {"project_id": made["project_id"], "genesis": genesis}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _wait(client, job_id: str) -> dict:
|
|
48
|
+
deadline = time.monotonic() + JOB_WAIT_SECONDS
|
|
49
|
+
while time.monotonic() < deadline:
|
|
50
|
+
job = client.job(job_id)
|
|
51
|
+
if job["status"] == "done":
|
|
52
|
+
return job["result"]
|
|
53
|
+
if job["status"] == "failed":
|
|
54
|
+
raise RuntimeError(f"the server could not prepare the seal: {job.get('error')}")
|
|
55
|
+
time.sleep(0.5)
|
|
56
|
+
raise RuntimeError("the server took too long to prepare the seal")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def seal_commit(client, root: Path, project: str, commit: str, kind: str, key,
|
|
60
|
+
exclude: list[str] | None = None, confirm=None, tag: str | None = None,
|
|
61
|
+
public_tag: bool = False) -> dict:
|
|
62
|
+
"""Seal one commit. Returns {already: ID}, {already, released: REL ID}, {release: result}
|
|
63
|
+
or {result, uploaded, upload_bytes, files}.
|
|
64
|
+
|
|
65
|
+
A release of a tree that is already sealed becomes a release record pointing at that seal;
|
|
66
|
+
``tag`` stays private (a salted commitment is public) unless ``public_tag``.
|
|
67
|
+
``confirm(count, size)`` is asked before any blob is uploaded; returning False stops.
|
|
68
|
+
"""
|
|
69
|
+
top = gitrepo.toplevel(root)
|
|
70
|
+
info = gitrepo.commit_info(top, commit)
|
|
71
|
+
under = gitrepo.prefix(root)
|
|
72
|
+
files = [f for f in gitrepo.ls_tree(top, commit, under)
|
|
73
|
+
if not f["path"].startswith(".imprint/")] # seals and receipts: not content
|
|
74
|
+
lines = linecount.count_commit(top, commit, files, under).payload()
|
|
75
|
+
pages = [files[i:i + PAGE] for i in range(0, len(files), PAGE)] or [[]]
|
|
76
|
+
plan_id, resp = None, {}
|
|
77
|
+
for i, page in enumerate(pages):
|
|
78
|
+
body = {"files": page, "more": i < len(pages) - 1}
|
|
79
|
+
if plan_id:
|
|
80
|
+
body["plan_id"] = plan_id
|
|
81
|
+
else:
|
|
82
|
+
body.update(tree=info["tree"], commit=commit, parents=info["parents"],
|
|
83
|
+
ref=info["ref"], kind=kind, exclude=list(exclude or []), lines=lines)
|
|
84
|
+
if kind == "release" and tag:
|
|
85
|
+
body.update(tag=tag, public_tag=public_tag)
|
|
86
|
+
resp = client.plan(project, body)
|
|
87
|
+
if "release" in resp:
|
|
88
|
+
rel = resp["release"]
|
|
89
|
+
result = client.finalise(project, rel["pending_id"], public_key(key),
|
|
90
|
+
sign(key, rel["statement"]))
|
|
91
|
+
return {"release": result, "commit": commit, "info": info, "tag": tag}
|
|
92
|
+
if "already_sealed" in resp:
|
|
93
|
+
# Engine 0.7.0a5+: already_sealed true and the earlier seal in full (its id);
|
|
94
|
+
# earlier engines: already_sealed is the seal's ID.
|
|
95
|
+
seal = resp["id"] if resp["already_sealed"] is True else resp["already_sealed"]
|
|
96
|
+
return {"already": seal, "released": resp.get("already_released"),
|
|
97
|
+
"commit": commit, "info": info}
|
|
98
|
+
plan_id = resp.get("plan_id", plan_id)
|
|
99
|
+
need = resp["need"]
|
|
100
|
+
sizes = {f["blob"]: f["size"] for f in files}
|
|
101
|
+
size = sum(sizes.get(b, 0) for b in need)
|
|
102
|
+
if need and confirm is not None and not confirm(len(need), size):
|
|
103
|
+
return {"cancelled": True}
|
|
104
|
+
batch, batch_size = [], 0
|
|
105
|
+
for blob, data in gitrepo.read_blobs(top, need):
|
|
106
|
+
if batch and batch_size + len(data) > BATCH_BYTES:
|
|
107
|
+
client.upload_blobs(project, batch)
|
|
108
|
+
batch, batch_size = [], 0
|
|
109
|
+
batch.append((blob, data))
|
|
110
|
+
batch_size += len(data)
|
|
111
|
+
if batch:
|
|
112
|
+
client.upload_blobs(project, batch)
|
|
113
|
+
prep = client.prepare(project, plan_id)
|
|
114
|
+
if "job_id" in prep:
|
|
115
|
+
prep = _wait(client, prep["job_id"])
|
|
116
|
+
result = client.finalise(project, prep["pending_id"], public_key(key),
|
|
117
|
+
sign(key, prep["statement"]))
|
|
118
|
+
check_lines(lines, result)
|
|
119
|
+
return {"result": result, "uploaded": len(need), "upload_bytes": size, "tag": tag,
|
|
120
|
+
"files": resp.get("files", 0), "commit": commit, "info": info, "lines": lines}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def check_lines(sent: dict, result: dict) -> None:
|
|
124
|
+
"""The Imprint you signed carries the count this client sent (older servers keep none)."""
|
|
125
|
+
kept = (result.get("imprint") or {}).get("lines")
|
|
126
|
+
if kept is not None and not result.get("already_sealed") and kept != sent:
|
|
127
|
+
raise RuntimeError("the server recorded a different line count than this client "
|
|
128
|
+
"counted; the seal is logged, but report this")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# -- local state ---------------------------------------------------------------------------
|
|
132
|
+
def _state(root: Path) -> Path:
|
|
133
|
+
path = gitrepo.git_dir(root) / "codeimprint"
|
|
134
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
return path
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def record(root: Path, seal: dict, commit: str, ref: str | None) -> None:
|
|
139
|
+
st = seal["receipt"]["attestation"]["statement"]
|
|
140
|
+
line = {"seal": seal["id"], "kind": st.get("kind"), "parents": st.get("parents", []),
|
|
141
|
+
"attested_at": st["attested_at"], "log_index": seal["receipt"]["log"]["index"],
|
|
142
|
+
"commit": commit, "ref": ref}
|
|
143
|
+
with (_state(root) / "chain.jsonl").open("a", encoding="utf-8") as f:
|
|
144
|
+
f.write(json.dumps(line, sort_keys=True) + "\n")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def local_chain(root: Path) -> list[dict]:
|
|
148
|
+
path = _state(root) / "chain.jsonl"
|
|
149
|
+
if not path.is_file():
|
|
150
|
+
return []
|
|
151
|
+
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def load_queue(root: Path) -> list[dict]:
|
|
155
|
+
path = _state(root) / "queue"
|
|
156
|
+
if not path.is_file():
|
|
157
|
+
return []
|
|
158
|
+
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def save_queue(root: Path, items: list[dict]) -> None:
|
|
162
|
+
path = _state(root) / "queue"
|
|
163
|
+
path.write_text("".join(json.dumps(i, sort_keys=True) + "\n" for i in items),
|
|
164
|
+
encoding="utf-8")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
HOOK_MARK = "# Added by `imprint hook install` (CodeImprint)"
|
|
168
|
+
HOOK_FILES = {"commit": "post-commit", "push": "pre-push"}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def hook_script(python: str, on: str) -> str:
|
|
172
|
+
"""A hook that never blocks: whatever happens, it exits 0."""
|
|
173
|
+
return (f"#!/bin/sh\n{HOOK_MARK}. It never blocks a {on}: when the server cannot be\n"
|
|
174
|
+
f"# reached, the seal is queued in .git/codeimprint/queue and sent on the next run.\n"
|
|
175
|
+
f'"{python}" -m codeimprint_cli hook run --on {on} || true\nexit 0\n')
|