cuff-cli 0.1.0__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.
cuff/ledger.py ADDED
@@ -0,0 +1,640 @@
1
+ """Closed claim/evidence ledgers and atomic command observations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import os
9
+ import re
10
+ import subprocess
11
+ import tempfile
12
+ import time
13
+ import uuid
14
+ from contextlib import contextmanager
15
+ from dataclasses import dataclass
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+ from typing import Any, Iterator
19
+
20
+ from .errors import CuffError
21
+ from .subject import SHA256_RE, current_subject, validate_subject
22
+
23
+
24
+ RECORDS_DIR = ".cuff/records"
25
+ WORK_ITEM_RE = re.compile(r"^[a-z0-9_.-]{1,120}$")
26
+ RECORD_ID_RE = re.compile(r"^rec_[0-9a-f]{32}$")
27
+ GIT_OID_RE = re.compile(r"^[0-9a-f]{40}(?:[0-9a-f]{24})?$")
28
+ CLAIM_FIELDS = {"v", "id", "type", "work_item", "created_at", "actor", "summary", "subject"}
29
+ EVIDENCE_FIELDS = {
30
+ "v", "id", "type", "work_item", "created_at", "actor", "claim",
31
+ "subject_digest", "command_digest", "exit_code", "output_digest", "provenance",
32
+ }
33
+ MAX_ACTOR_BYTES = 256
34
+ MAX_SUMMARY_BYTES = 4096
35
+ MAX_COMMAND_ARGUMENTS = 256
36
+ MAX_COMMAND_BYTES = 64 * 1024
37
+ MAX_RETAINED_OUTPUT = 1024 * 1024
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class LedgerBaseline:
42
+ length: int
43
+ digest: str
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Observation:
48
+ stdout: bytes
49
+ stderr: bytes
50
+ output_digest: str
51
+ exit_code: int
52
+ timed_out: bool = False
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Verification:
57
+ record: dict[str, Any]
58
+ stdout: bytes
59
+ stderr: bytes
60
+ path: Path
61
+ line: int
62
+ timed_out: bool = False
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Sealing:
67
+ claim: dict[str, Any]
68
+ evidence: dict[str, Any]
69
+ stdout: bytes
70
+ stderr: bytes
71
+ path: Path
72
+ lines: list[int]
73
+ timed_out: bool = False
74
+
75
+
76
+ def normalize_work_item(value: str | None) -> str:
77
+ if value is None:
78
+ raise CuffError("CUFF_WORK_ITEM_REQUIRED", "A work item is required")
79
+ if not isinstance(value, str):
80
+ raise CuffError("CUFF_WORK_ITEM_INVALID", "Work item must be a short workspace-safe name")
81
+ normalized = re.sub(r"[^a-z0-9_.-]+", "-", value.strip().lower()).strip("-")
82
+ if not normalized or normalized in {".", ".."} or WORK_ITEM_RE.fullmatch(normalized) is None:
83
+ raise CuffError("CUFF_WORK_ITEM_INVALID", "Work item must be a short workspace-safe name")
84
+ return normalized
85
+
86
+
87
+ def resolve_actor(explicit: str | None) -> str:
88
+ actor = explicit if explicit is not None else os.environ.get("CUFF_ACTOR")
89
+ return _bounded_text(actor if actor is not None else "human:unknown", "actor", MAX_ACTOR_BYTES)
90
+
91
+
92
+ def init(root: Path) -> Path:
93
+ path = root / RECORDS_DIR
94
+ if (root / ".cuff").is_symlink() or path.is_symlink():
95
+ raise CuffError("CUFF_PATH_INVALID", "Cuff workspace directories must not be symlinks")
96
+ path.mkdir(parents=True, exist_ok=True)
97
+ read_all(root)
98
+ return path
99
+
100
+
101
+ def record_path(root: Path, work_item: str) -> Path:
102
+ return root / RECORDS_DIR / f"{normalize_work_item(work_item)}.jsonl"
103
+
104
+
105
+ def baseline(root: Path, work_item: str) -> LedgerBaseline:
106
+ path = record_path(root, work_item)
107
+ content = path.read_bytes() if path.exists() else b""
108
+ return _content_baseline(content)
109
+
110
+
111
+ def create_claim(
112
+ root: Path,
113
+ work_item: str,
114
+ summary: str,
115
+ subject: dict[str, str],
116
+ actor: str | None = None,
117
+ ) -> dict[str, Any]:
118
+ selected_actor = resolve_actor(actor)
119
+ record = {
120
+ **_base_record("claim", work_item, selected_actor),
121
+ "summary": _bounded_text(summary.strip(), "summary", MAX_SUMMARY_BYTES),
122
+ "subject": validate_subject(subject),
123
+ }
124
+ validate_record(record)
125
+ return record
126
+
127
+
128
+ def verify(
129
+ root: Path,
130
+ work_item: str,
131
+ claim_id: str,
132
+ command: list[str],
133
+ *,
134
+ timeout: float = 300,
135
+ actor: str | None = None,
136
+ ) -> Verification:
137
+ normalized = normalize_work_item(work_item)
138
+ records = read(root, normalized)
139
+ claim = next(
140
+ (record for record in records if record["type"] == "claim" and record["id"] == claim_id),
141
+ None,
142
+ )
143
+ if claim is None:
144
+ raise CuffError("CUFF_CLAIM_UNKNOWN", "Evidence must link to a claim in the same work item")
145
+ expected = baseline(root, normalized)
146
+ subject = _capture_subject(root, claim["subject"])
147
+ git_state = _capture_git(root)
148
+ observation = observe_command(root, command, timeout=timeout)
149
+ _require_unchanged_subject(root, subject)
150
+ evidence_provenance = _finish_provenance(root, git_state)
151
+ evidence = _create_evidence(
152
+ normalized,
153
+ claim,
154
+ command,
155
+ observation,
156
+ evidence_provenance,
157
+ resolve_actor(actor),
158
+ )
159
+ path, lines = append_many(root, [evidence], expected=expected)
160
+ return Verification(
161
+ evidence,
162
+ observation.stdout,
163
+ observation.stderr,
164
+ path,
165
+ lines[0],
166
+ observation.timed_out,
167
+ )
168
+
169
+
170
+ def seal(
171
+ root: Path,
172
+ work_item: str,
173
+ summary: str,
174
+ subject: dict[str, str],
175
+ command: list[str],
176
+ *,
177
+ timeout: float = 300,
178
+ actor: str | None = None,
179
+ ) -> Sealing:
180
+ normalized = normalize_work_item(work_item)
181
+ read(root, normalized)
182
+ expected = baseline(root, normalized)
183
+ captured_subject = _capture_subject(root, subject)
184
+ selected_actor = resolve_actor(actor)
185
+ claim = create_claim(root, normalized, summary, captured_subject, selected_actor)
186
+ git_state = _capture_git(root)
187
+ observation = observe_command(root, command, timeout=timeout)
188
+ _require_unchanged_subject(root, captured_subject)
189
+ evidence_provenance = _finish_provenance(root, git_state)
190
+ evidence = _create_evidence(
191
+ normalized,
192
+ claim,
193
+ command,
194
+ observation,
195
+ evidence_provenance,
196
+ selected_actor,
197
+ )
198
+ path, lines = append_many(root, [claim, evidence], expected=expected)
199
+ return Sealing(
200
+ claim,
201
+ evidence,
202
+ observation.stdout,
203
+ observation.stderr,
204
+ path,
205
+ lines,
206
+ observation.timed_out,
207
+ )
208
+
209
+
210
+ def observe_command(root: Path, command: list[str], *, timeout: float = 300) -> Observation:
211
+ _validate_command(command, timeout)
212
+ timed_out = False
213
+ with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
214
+ try:
215
+ process = subprocess.Popen(
216
+ command,
217
+ cwd=root,
218
+ stdout=stdout_file,
219
+ stderr=stderr_file,
220
+ )
221
+ except OSError as exc:
222
+ raise CuffError("CUFF_COMMAND_FAILED", f"Verification command could not start: {exc}") from exc
223
+ try:
224
+ exit_code = process.wait(timeout=timeout)
225
+ except subprocess.TimeoutExpired:
226
+ timed_out = True
227
+ process.kill()
228
+ process.wait()
229
+ exit_code = 124
230
+ except KeyboardInterrupt:
231
+ process.kill()
232
+ process.wait()
233
+ raise
234
+ digest = _output_digest(stdout_file, stderr_file)
235
+ stdout, stderr = _retained_output(stdout_file, stderr_file)
236
+ return Observation(stdout, stderr, digest, exit_code, timed_out)
237
+
238
+
239
+ def append(root: Path, record: dict[str, Any]) -> tuple[Path, int]:
240
+ path, lines = append_many(root, [record])
241
+ return path, lines[0]
242
+
243
+
244
+ def append_many(
245
+ root: Path,
246
+ records: list[dict[str, Any]],
247
+ *,
248
+ expected: LedgerBaseline | None = None,
249
+ ) -> tuple[Path, list[int]]:
250
+ if not records:
251
+ raise CuffError("CUFF_LEDGER_INVALID", "At least one record is required")
252
+ for record in records:
253
+ validate_record(record)
254
+ work_items = {record["work_item"] for record in records}
255
+ if len(work_items) != 1:
256
+ raise CuffError("CUFF_LEDGER_INVALID", "Atomic records must share one work item")
257
+ work_item = next(iter(work_items))
258
+ path = record_path(root, work_item)
259
+ directory = root / RECORDS_DIR
260
+ if not directory.is_dir():
261
+ raise CuffError("CUFF_NOT_INITIALIZED", "Run cuff init first")
262
+ if directory.is_symlink() or path.is_symlink():
263
+ raise CuffError("CUFF_PATH_INVALID", "Cuff ledgers must not be symlinks")
264
+ with _lock(path):
265
+ existing = path.read_bytes() if path.exists() else b""
266
+ if expected is not None and _content_baseline(existing) != expected:
267
+ raise CuffError(
268
+ "CUFF_CONCURRENT_UPDATE",
269
+ "The ledger changed while verification was running",
270
+ )
271
+ prior = _parse(existing, path)
272
+ _require_ledger_work_item(prior, work_item, path)
273
+ content = existing + b"".join((_canonical(record) + "\n").encode() for record in records)
274
+ _require_ledger_work_item(_parse(content, path), work_item, path)
275
+ first_line = len(prior) + 1
276
+ _replace(path, content)
277
+ return path, list(range(first_line, first_line + len(records)))
278
+
279
+
280
+ def read(root: Path, work_item: str) -> list[dict[str, Any]]:
281
+ normalized = normalize_work_item(work_item)
282
+ directory = root / RECORDS_DIR
283
+ if directory.is_symlink():
284
+ raise CuffError("CUFF_PATH_INVALID", "Cuff records directory must not be a symlink")
285
+ path = record_path(root, normalized)
286
+ if not path.exists():
287
+ return []
288
+ if path.is_symlink():
289
+ raise CuffError("CUFF_PATH_INVALID", "Cuff ledgers must not be symlinks")
290
+ records = _parse(path.read_bytes(), path)
291
+ _require_ledger_work_item(records, normalized, path)
292
+ return records
293
+
294
+
295
+ def read_all(root: Path) -> dict[str, list[dict[str, Any]]]:
296
+ directory = root / RECORDS_DIR
297
+ if not directory.is_dir():
298
+ raise CuffError("CUFF_NOT_INITIALIZED", "Run cuff init first")
299
+ if directory.is_symlink():
300
+ raise CuffError("CUFF_PATH_INVALID", "Cuff records directory must not be a symlink")
301
+ ledgers: dict[str, list[dict[str, Any]]] = {}
302
+ for path in sorted(directory.glob("*.jsonl")):
303
+ if path.is_symlink():
304
+ raise CuffError("CUFF_PATH_INVALID", "Cuff ledgers must not be symlinks", {"path": str(path)})
305
+ work_item = normalize_work_item(path.stem)
306
+ records = _parse(path.read_bytes(), path)
307
+ _require_ledger_work_item(records, work_item, path)
308
+ ledgers[work_item] = records
309
+ return ledgers
310
+
311
+
312
+ def validate_record(record: Any) -> None:
313
+ if not isinstance(record, dict):
314
+ raise CuffError("CUFF_LEDGER_INVALID", "Each JSONL line must be an object")
315
+ if "git_ref" in record:
316
+ raise CuffError(
317
+ "CUFF_LEDGER_INVALID",
318
+ "Ledger schema is incompatible; archive or remove .cuff and run cuff init",
319
+ )
320
+ record_type = record.get("type")
321
+ allowed = (
322
+ CLAIM_FIELDS
323
+ if record_type == "claim"
324
+ else EVIDENCE_FIELDS
325
+ if record_type == "evidence"
326
+ else set()
327
+ )
328
+ if not allowed or set(record) != allowed:
329
+ raise CuffError(
330
+ "CUFF_LEDGER_INVALID",
331
+ "Record fields or type are invalid",
332
+ {"record_id": record.get("id")},
333
+ )
334
+ if (
335
+ record.get("v") != 1
336
+ or not isinstance(record.get("id"), str)
337
+ or RECORD_ID_RE.fullmatch(record["id"]) is None
338
+ ):
339
+ raise CuffError("CUFF_LEDGER_INVALID", "Record version or id is invalid")
340
+ if normalize_work_item(record.get("work_item")) != record["work_item"]:
341
+ raise CuffError("CUFF_LEDGER_INVALID", "Work item is not canonical")
342
+ _validate_timestamp(record.get("created_at"))
343
+ _bounded_text(record.get("actor"), "actor", MAX_ACTOR_BYTES)
344
+ if record_type == "claim":
345
+ _bounded_text(record.get("summary"), "summary", MAX_SUMMARY_BYTES)
346
+ validate_subject(record.get("subject"))
347
+ return
348
+ if not isinstance(record["claim"], str) or RECORD_ID_RE.fullmatch(record["claim"]) is None:
349
+ raise CuffError("CUFF_LEDGER_INVALID", "Evidence claim link is invalid")
350
+ if type(record["exit_code"]) is not int:
351
+ raise CuffError("CUFF_LEDGER_INVALID", "Evidence exit_code must be an integer")
352
+ for field in ("subject_digest", "command_digest", "output_digest"):
353
+ if not isinstance(record[field], str) or SHA256_RE.fullmatch(record[field]) is None:
354
+ raise CuffError("CUFF_LEDGER_INVALID", f"Evidence {field} is invalid")
355
+ _validate_provenance(record["provenance"])
356
+
357
+
358
+ def _create_evidence(
359
+ work_item: str,
360
+ claim: dict[str, Any],
361
+ command: list[str],
362
+ observation: Observation,
363
+ provenance: dict[str, str],
364
+ actor: str,
365
+ ) -> dict[str, Any]:
366
+ record = {
367
+ **_base_record("evidence", work_item, actor),
368
+ "claim": claim["id"],
369
+ "subject_digest": claim["subject"]["digest"],
370
+ "command_digest": "sha256:" + hashlib.sha256(_canonical(command).encode()).hexdigest(),
371
+ "exit_code": observation.exit_code,
372
+ "output_digest": observation.output_digest,
373
+ "provenance": provenance,
374
+ }
375
+ validate_record(record)
376
+ return record
377
+
378
+
379
+ def _base_record(record_type: str, work_item: str, actor: str) -> dict[str, Any]:
380
+ return {
381
+ "v": 1,
382
+ "id": "rec_" + uuid.uuid4().hex,
383
+ "type": record_type,
384
+ "work_item": normalize_work_item(work_item),
385
+ "created_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
386
+ "actor": _bounded_text(actor, "actor", MAX_ACTOR_BYTES),
387
+ }
388
+
389
+
390
+ def _parse(content: bytes, path: Path) -> list[dict[str, Any]]:
391
+ if not content:
392
+ return []
393
+ if not content.endswith(b"\n"):
394
+ raise CuffError("CUFF_LEDGER_INVALID", "JSONL ledger must end with a newline", {"path": str(path)})
395
+ records: list[dict[str, Any]] = []
396
+ for line_number, raw in enumerate(content.splitlines(), 1):
397
+ try:
398
+ record = json.loads(raw, object_pairs_hook=_no_duplicate_keys)
399
+ validate_record(record)
400
+ except (UnicodeError, json.JSONDecodeError, ValueError) as exc:
401
+ raise CuffError(
402
+ "CUFF_LEDGER_INVALID", "Ledger contains invalid JSON", {"path": str(path), "line": line_number}
403
+ ) from exc
404
+ except CuffError as exc:
405
+ raise CuffError(
406
+ exc.code,
407
+ exc.message,
408
+ {**exc.context, "path": str(path), "line": line_number},
409
+ ) from exc
410
+ records.append(record)
411
+ ids = [record["id"] for record in records]
412
+ if len(ids) != len(set(ids)):
413
+ raise CuffError("CUFF_LEDGER_INVALID", "Ledger contains duplicate record ids", {"path": str(path)})
414
+ claims: dict[str, dict[str, Any]] = {}
415
+ for record in records:
416
+ if record["type"] == "claim":
417
+ claims[record["id"]] = record
418
+ continue
419
+ claim = claims.get(record["claim"])
420
+ if claim is None:
421
+ raise CuffError(
422
+ "CUFF_LEDGER_INVALID",
423
+ "Evidence links to an unknown claim",
424
+ {"record_id": record["id"]},
425
+ )
426
+ if record["work_item"] != claim["work_item"]:
427
+ raise CuffError(
428
+ "CUFF_LEDGER_INVALID",
429
+ "Evidence work item differs from its claim",
430
+ {"record_id": record["id"]},
431
+ )
432
+ if record["subject_digest"] != claim["subject"]["digest"]:
433
+ raise CuffError(
434
+ "CUFF_LEDGER_INVALID",
435
+ "Evidence subject digest differs from its claim",
436
+ {"record_id": record["id"]},
437
+ )
438
+ return records
439
+
440
+
441
+ def _require_ledger_work_item(
442
+ records: list[dict[str, Any]],
443
+ work_item: str,
444
+ path: Path,
445
+ ) -> None:
446
+ if any(record["work_item"] != work_item for record in records):
447
+ raise CuffError(
448
+ "CUFF_LEDGER_INVALID",
449
+ "Record work item does not match its ledger",
450
+ {"path": str(path)},
451
+ )
452
+
453
+
454
+ def _capture_subject(root: Path, subject: dict[str, str]) -> dict[str, str]:
455
+ declared = validate_subject(subject)
456
+ observed = current_subject(root, declared)
457
+ if observed["digest"] != declared["digest"]:
458
+ raise CuffError("CUFF_SUBJECT_CHANGED", "Filesystem subject does not match its declared digest")
459
+ return declared
460
+
461
+
462
+ def _require_unchanged_subject(root: Path, subject: dict[str, str]) -> None:
463
+ try:
464
+ observed = current_subject(root, subject)
465
+ except CuffError as exc:
466
+ raise CuffError(
467
+ "CUFF_SUBJECT_CHANGED",
468
+ "Subject changed during verification; no evidence was recorded",
469
+ ) from exc
470
+ if observed != subject:
471
+ raise CuffError(
472
+ "CUFF_SUBJECT_CHANGED",
473
+ "Subject changed during verification; no evidence was recorded",
474
+ )
475
+
476
+
477
+ def _capture_git(root: Path) -> tuple[Path, str]:
478
+ from . import git
479
+
480
+ workspace = root.resolve()
481
+ repository = git.repo_root(workspace)
482
+ if repository != workspace:
483
+ raise CuffError(
484
+ "CUFF_WORKSPACE_NOT_ROOT",
485
+ "Cuff workspace must be the Git worktree root",
486
+ {"workspace": str(workspace), "repository": str(repository)},
487
+ )
488
+ dirty = [path for path in git.dirty_paths(repository) if not _is_record_path(repository, root, path)]
489
+ if dirty:
490
+ raise CuffError(
491
+ "CUFF_REPOSITORY_DIRTY",
492
+ "Commit or remove non-ledger changes before Git-provenance verification",
493
+ {"paths": dirty},
494
+ )
495
+ return repository, git.head(repository)
496
+
497
+
498
+ def _finish_provenance(root: Path, state: tuple[Path, str]) -> dict[str, str]:
499
+ repository, commit = state
500
+ from . import git
501
+
502
+ current_repository = git.repo_root(root)
503
+ current_commit = git.head(current_repository)
504
+ if current_repository != repository or current_commit != commit:
505
+ raise CuffError(
506
+ "CUFF_REPOSITORY_CHANGED",
507
+ "Verification command changed Git HEAD; no evidence was recorded",
508
+ )
509
+ dirty = [path for path in git.dirty_paths(repository) if not _is_record_path(repository, root, path)]
510
+ if dirty:
511
+ raise CuffError(
512
+ "CUFF_REPOSITORY_DIRTY",
513
+ "Verification command left non-ledger Git changes; no evidence was recorded",
514
+ {"paths": dirty},
515
+ )
516
+ return {"kind": "git", "commit": commit}
517
+
518
+
519
+ def _is_record_path(repository: Path, root: Path, path: str) -> bool:
520
+ try:
521
+ prefix = (root / RECORDS_DIR).resolve().relative_to(repository.resolve()).as_posix()
522
+ except ValueError:
523
+ return False
524
+ normalized = path.replace("\\", "/")
525
+ return normalized == prefix or normalized.startswith(prefix + "/")
526
+
527
+
528
+ def _validate_provenance(value: Any) -> dict[str, str]:
529
+ if not isinstance(value, dict):
530
+ raise CuffError("CUFF_LEDGER_INVALID", "Evidence provenance must be an object")
531
+ if set(value) == {"kind", "commit"} and value.get("kind") == "git":
532
+ commit = value.get("commit")
533
+ if isinstance(commit, str) and GIT_OID_RE.fullmatch(commit) is not None:
534
+ return value
535
+ raise CuffError("CUFF_LEDGER_INVALID", "Evidence provenance is invalid")
536
+
537
+
538
+ def _validate_command(command: list[str], timeout: float) -> None:
539
+ if not command:
540
+ raise CuffError("CUFF_COMMAND_REQUIRED", "Pass a verification command after --")
541
+ if (
542
+ len(command) > MAX_COMMAND_ARGUMENTS
543
+ or any(not isinstance(argument, str) or not argument or "\0" in argument for argument in command)
544
+ or sum(len(argument.encode()) for argument in command) > MAX_COMMAND_BYTES
545
+ ):
546
+ raise CuffError("CUFF_COMMAND_INVALID", "Verification command exceeds its argument bounds")
547
+ if not math.isfinite(timeout) or timeout <= 0:
548
+ raise CuffError("CUFF_TIMEOUT_INVALID", "Verification timeout must be a positive number")
549
+
550
+
551
+ def _validate_timestamp(value: Any) -> None:
552
+ if not isinstance(value, str) or not value.endswith("Z"):
553
+ raise CuffError("CUFF_LEDGER_INVALID", "created_at must be a UTC timestamp ending in Z")
554
+ try:
555
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
556
+ except ValueError as exc:
557
+ raise CuffError("CUFF_LEDGER_INVALID", "created_at must be an ISO timestamp") from exc
558
+ if parsed.utcoffset() != UTC.utcoffset(parsed):
559
+ raise CuffError("CUFF_LEDGER_INVALID", "created_at must be UTC")
560
+
561
+
562
+ def _bounded_text(value: Any, field: str, maximum: int) -> str:
563
+ if not isinstance(value, str) or not value or len(value.encode("utf-8")) > maximum:
564
+ raise CuffError("CUFF_LEDGER_INVALID", f"Record {field} must be bounded nonempty text")
565
+ if any(ord(character) < 32 or ord(character) == 127 for character in value):
566
+ raise CuffError("CUFF_LEDGER_INVALID", f"Record {field} must not contain controls")
567
+ return value
568
+
569
+
570
+ def _output_digest(stdout: Any, stderr: Any) -> str:
571
+ digest = hashlib.sha256(b"cuff-output-1\0")
572
+ for handle in (stdout, stderr):
573
+ length = handle.seek(0, os.SEEK_END)
574
+ digest.update(length.to_bytes(8, "big"))
575
+ handle.seek(0)
576
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
577
+ digest.update(chunk)
578
+ return "sha256:" + digest.hexdigest()
579
+
580
+
581
+ def _retained_output(stdout: Any, stderr: Any) -> tuple[bytes, bytes]:
582
+ stdout.seek(0)
583
+ retained_stdout = stdout.read(MAX_RETAINED_OUTPUT)
584
+ stderr.seek(0)
585
+ retained_stderr = stderr.read(MAX_RETAINED_OUTPUT - len(retained_stdout))
586
+ return retained_stdout, retained_stderr
587
+
588
+
589
+ def _content_baseline(content: bytes) -> LedgerBaseline:
590
+ return LedgerBaseline(len(content), "sha256:" + hashlib.sha256(content).hexdigest())
591
+
592
+
593
+ def _replace(path: Path, content: bytes) -> None:
594
+ temporary: Path | None = None
595
+ try:
596
+ with tempfile.NamedTemporaryFile("wb", dir=path.parent, prefix=f".{path.stem}.", delete=False) as handle:
597
+ temporary = Path(handle.name)
598
+ handle.write(content)
599
+ handle.flush()
600
+ os.fsync(handle.fileno())
601
+ os.replace(temporary, path)
602
+ descriptor = os.open(path.parent, os.O_RDONLY)
603
+ try:
604
+ os.fsync(descriptor)
605
+ finally:
606
+ os.close(descriptor)
607
+ finally:
608
+ if temporary is not None and temporary.exists():
609
+ temporary.unlink()
610
+
611
+
612
+ def _no_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
613
+ data: dict[str, Any] = {}
614
+ for key, value in pairs:
615
+ if key in data:
616
+ raise ValueError(f"duplicate JSON key: {key}")
617
+ data[key] = value
618
+ return data
619
+
620
+
621
+ def _canonical(value: Any) -> str:
622
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
623
+
624
+
625
+ @contextmanager
626
+ def _lock(path: Path) -> Iterator[None]:
627
+ lock = path.with_suffix(".lock")
628
+ deadline = time.monotonic() + 5
629
+ while True:
630
+ try:
631
+ lock.mkdir()
632
+ break
633
+ except FileExistsError:
634
+ if time.monotonic() >= deadline:
635
+ raise CuffError("CUFF_LEDGER_BUSY", "Another writer holds the ledger lock")
636
+ time.sleep(0.05)
637
+ try:
638
+ yield
639
+ finally:
640
+ lock.rmdir()