model-mirror-cli 0.2.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.
@@ -0,0 +1,400 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Callable
8
+
9
+ from .checksums import FileHashes, file_hashes
10
+ from .torrent import PUBLICATION_PROFILE, SAFE_COMMIT, TorrentPublicationError, select_piece_length
11
+ from .torrent_hashes import TorrentFileHasher, TorrentFileHashes
12
+ from .verify import metadata_blob_id, metadata_lfs_sha256, metadata_path, metadata_size
13
+
14
+
15
+ COVERAGE_SCHEMA = "model-mirror-torrent-coverage"
16
+ COVERAGE_VERSION = 1
17
+ COVERAGE_DIR = Path(".model-mirror") / "torrent" / "coverage"
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class TorrentCoverageFile:
22
+ path: str
23
+ size: int
24
+ mtime_ns: int
25
+ sha256: str
26
+ git_blob_sha1: str
27
+ lfs_sha256: str | None
28
+ blob_id: str | None
29
+ v1_piece_hashes: tuple[str, ...]
30
+ v2_piece_hashes: tuple[str, ...]
31
+ v2_file_root: str | None
32
+
33
+ def to_dict(self) -> dict:
34
+ return {
35
+ "path": self.path,
36
+ "size": self.size,
37
+ "mtime_ns": self.mtime_ns,
38
+ "sha256": self.sha256,
39
+ "git_blob_sha1": self.git_blob_sha1,
40
+ "lfs_sha256": self.lfs_sha256,
41
+ "blob_id": self.blob_id,
42
+ "v1_piece_hashes": list(self.v1_piece_hashes),
43
+ "v2_piece_hashes": list(self.v2_piece_hashes),
44
+ "v2_file_root": self.v2_file_root,
45
+ }
46
+
47
+ @classmethod
48
+ def from_dict(cls, value: object, *, source: Path) -> TorrentCoverageFile:
49
+ if not isinstance(value, dict):
50
+ raise ValueError(f"Malformed torrent coverage file row in {source}")
51
+ try:
52
+ path = str(value["path"])
53
+ size = int(value["size"])
54
+ mtime_ns = int(value["mtime_ns"])
55
+ sha256 = coverage_hex(value["sha256"], 64, "SHA-256", source)
56
+ git_blob_sha1 = coverage_hex(value["git_blob_sha1"], 40, "Git blob SHA-1", source)
57
+ lfs_sha256 = optional_coverage_hex(value.get("lfs_sha256"), 64, "LFS SHA-256", source)
58
+ blob_id = optional_coverage_hex(value.get("blob_id"), 40, "blob ID", source)
59
+ v1_piece_hashes = tuple(
60
+ coverage_hex(item, 40, "v1 piece hash", source)
61
+ for item in value["v1_piece_hashes"]
62
+ )
63
+ v2_piece_hashes = tuple(
64
+ coverage_hex(item, 64, "v2 piece hash", source)
65
+ for item in value["v2_piece_hashes"]
66
+ )
67
+ v2_file_root = optional_coverage_hex(
68
+ value.get("v2_file_root"),
69
+ 64,
70
+ "v2 file root",
71
+ source,
72
+ )
73
+ except (KeyError, TypeError, ValueError) as exc:
74
+ if isinstance(exc, ValueError) and str(exc).startswith("Invalid "):
75
+ raise
76
+ raise ValueError(f"Malformed torrent coverage file row in {source}") from exc
77
+ if not path or size < 0 or mtime_ns < 0:
78
+ raise ValueError(f"Malformed torrent coverage file row in {source}")
79
+ return cls(
80
+ path=path,
81
+ size=size,
82
+ mtime_ns=mtime_ns,
83
+ sha256=sha256,
84
+ git_blob_sha1=git_blob_sha1,
85
+ lfs_sha256=lfs_sha256,
86
+ blob_id=blob_id,
87
+ v1_piece_hashes=v1_piece_hashes,
88
+ v2_piece_hashes=v2_piece_hashes,
89
+ v2_file_root=v2_file_root,
90
+ )
91
+
92
+
93
+ @dataclass(slots=True)
94
+ class TorrentCoverage:
95
+ repo_id: str
96
+ repo_type: str
97
+ resolved_commit: str
98
+ piece_length: int
99
+ files: dict[str, TorrentCoverageFile] = field(default_factory=dict)
100
+ profile: str = PUBLICATION_PROFILE
101
+
102
+ def to_dict(self) -> dict:
103
+ return {
104
+ "schema": COVERAGE_SCHEMA,
105
+ "version": COVERAGE_VERSION,
106
+ "profile": self.profile,
107
+ "repo_id": self.repo_id,
108
+ "repo_type": self.repo_type,
109
+ "resolved_commit": self.resolved_commit,
110
+ "piece_length": self.piece_length,
111
+ "files": {path: self.files[path].to_dict() for path in sorted(self.files)},
112
+ }
113
+
114
+
115
+ @dataclass(frozen=True, slots=True)
116
+ class CoverageUpgradeResult:
117
+ path: Path
118
+ total_files: int
119
+ covered_files: int
120
+ hashed_files: int
121
+ hashed_bytes: int
122
+ complete: bool
123
+ dry_run: bool = False
124
+
125
+
126
+ class TorrentCoverageRecorder:
127
+ def __init__(self, root: Path, snapshot, *, profile: str = PUBLICATION_PROFILE):
128
+ self.root = root
129
+ self.snapshot = snapshot
130
+ self.profile = profile
131
+ self.expected = snapshot_files(snapshot)
132
+ total_size = sum(require_metadata_size(item) for item in self.expected.values())
133
+ if not self.expected or total_size == 0:
134
+ raise TorrentPublicationError("torrent coverage requires at least one non-empty payload file")
135
+ self.piece_length = select_piece_length(total_size)
136
+ self.path = coverage_path(root, snapshot.resolved_commit, profile)
137
+ self.coverage = load_coverage(self.path)
138
+ if self.coverage is None:
139
+ self.coverage = TorrentCoverage(
140
+ repo_id=snapshot.repo_id,
141
+ repo_type=snapshot.repo_type,
142
+ resolved_commit=snapshot.resolved_commit,
143
+ piece_length=self.piece_length,
144
+ profile=profile,
145
+ )
146
+ self._validate_identity()
147
+ self._lock = threading.Lock()
148
+
149
+ def accumulator(self, item) -> TorrentFileHasher:
150
+ return TorrentFileHasher(
151
+ expected_size=require_metadata_size(item),
152
+ piece_length=self.piece_length,
153
+ pad_v1_tail=len(self.expected) > 1,
154
+ )
155
+
156
+ def record(
157
+ self,
158
+ item,
159
+ path: Path,
160
+ full_hashes: FileHashes,
161
+ torrent_hashes: TorrentFileHashes,
162
+ ) -> TorrentCoverageFile:
163
+ rel = metadata_path(item)
164
+ expected_size = require_metadata_size(item)
165
+ stat = path.stat()
166
+ if stat.st_size != expected_size:
167
+ raise TorrentPublicationError(
168
+ f"cannot record torrent coverage for {rel}: expected {expected_size} bytes, got {stat.st_size}"
169
+ )
170
+ ensure_upstream_hashes(item, full_hashes)
171
+ row = TorrentCoverageFile(
172
+ path=rel,
173
+ size=stat.st_size,
174
+ mtime_ns=stat.st_mtime_ns,
175
+ sha256=full_hashes.sha256,
176
+ git_blob_sha1=full_hashes.git_blob_sha1,
177
+ lfs_sha256=metadata_lfs_sha256(item),
178
+ blob_id=metadata_blob_id(item),
179
+ v1_piece_hashes=tuple(value.hex() for value in torrent_hashes.v1_piece_hashes),
180
+ v2_piece_hashes=tuple(value.hex() for value in torrent_hashes.v2_piece_hashes),
181
+ v2_file_root=(
182
+ torrent_hashes.v2_file_root.hex()
183
+ if torrent_hashes.v2_file_root is not None
184
+ else None
185
+ ),
186
+ )
187
+ with self._lock:
188
+ self.coverage.files[rel] = row
189
+ write_coverage(self.path, self.coverage)
190
+ return row
191
+
192
+ def missing_paths(self) -> list[str]:
193
+ missing = []
194
+ for rel, item in self.expected.items():
195
+ path = self.root / rel
196
+ row = self.coverage.files.get(rel)
197
+ if not coverage_row_is_current(row, item, path, self.piece_length):
198
+ missing.append(rel)
199
+ return missing
200
+
201
+ @property
202
+ def complete(self) -> bool:
203
+ return not self.missing_paths()
204
+
205
+ def _validate_identity(self) -> None:
206
+ expected = (
207
+ self.snapshot.repo_id,
208
+ self.snapshot.repo_type,
209
+ self.snapshot.resolved_commit,
210
+ self.profile,
211
+ self.piece_length,
212
+ )
213
+ actual = (
214
+ self.coverage.repo_id,
215
+ self.coverage.repo_type,
216
+ self.coverage.resolved_commit,
217
+ self.coverage.profile,
218
+ self.coverage.piece_length,
219
+ )
220
+ if actual != expected:
221
+ raise ValueError(
222
+ f"Torrent coverage identity mismatch in {self.path}: expected {expected!r}, found {actual!r}"
223
+ )
224
+
225
+
226
+ def snapshot_files(snapshot) -> dict[str, object]:
227
+ result: dict[str, object] = {}
228
+ for item in sorted(snapshot.files, key=lambda candidate: metadata_path(candidate).encode("utf-8")):
229
+ rel = metadata_path(item)
230
+ if rel in result:
231
+ raise TorrentPublicationError(f"duplicate payload path in pinned snapshot: {rel}")
232
+ result[rel] = item
233
+ return result
234
+
235
+
236
+ def require_metadata_size(item) -> int:
237
+ size = metadata_size(item)
238
+ if size is None or size < 0:
239
+ raise TorrentPublicationError(f"missing or invalid expected size for {metadata_path(item)}")
240
+ return size
241
+
242
+
243
+ def coverage_path(root: Path, resolved_commit: str, profile: str = PUBLICATION_PROFILE) -> Path:
244
+ if SAFE_COMMIT.fullmatch(resolved_commit) is None:
245
+ raise TorrentPublicationError(f"invalid resolved commit for torrent coverage: {resolved_commit!r}")
246
+ return root / COVERAGE_DIR / f"{profile}--{resolved_commit}.json"
247
+
248
+
249
+ def load_coverage(path: Path) -> TorrentCoverage | None:
250
+ if not path.exists():
251
+ return None
252
+ try:
253
+ value = json.loads(path.read_text(encoding="utf-8"))
254
+ except Exception as exc:
255
+ raise ValueError(f"Malformed torrent coverage metadata: {path}") from exc
256
+ if not isinstance(value, dict):
257
+ raise ValueError(f"Torrent coverage metadata must contain an object: {path}")
258
+ if value.get("schema") != COVERAGE_SCHEMA:
259
+ raise ValueError(f"Unsupported torrent coverage schema in {path}: {value.get('schema')}")
260
+ if value.get("version") != COVERAGE_VERSION:
261
+ raise ValueError(f"Unsupported torrent coverage version in {path}: {value.get('version')}")
262
+ files_value = value.get("files", {})
263
+ if not isinstance(files_value, dict):
264
+ raise ValueError(f"Malformed torrent coverage file map in {path}")
265
+ try:
266
+ coverage = TorrentCoverage(
267
+ repo_id=str(value["repo_id"]),
268
+ repo_type=str(value["repo_type"]),
269
+ resolved_commit=str(value["resolved_commit"]),
270
+ piece_length=int(value["piece_length"]),
271
+ profile=str(value["profile"]),
272
+ )
273
+ except (KeyError, TypeError, ValueError) as exc:
274
+ raise ValueError(f"Malformed torrent coverage metadata: {path}") from exc
275
+ for rel, row_value in files_value.items():
276
+ row = TorrentCoverageFile.from_dict(row_value, source=path)
277
+ if rel != row.path or rel in coverage.files:
278
+ raise ValueError(f"Malformed torrent coverage file map in {path}")
279
+ coverage.files[rel] = row
280
+ return coverage
281
+
282
+
283
+ def write_coverage(path: Path, coverage: TorrentCoverage) -> None:
284
+ path.parent.mkdir(parents=True, exist_ok=True)
285
+ temporary = path.with_name(f"{path.name}.tmp")
286
+ temporary.write_text(
287
+ json.dumps(coverage.to_dict(), sort_keys=True, indent=2) + "\n",
288
+ encoding="utf-8",
289
+ )
290
+ temporary.replace(path)
291
+
292
+
293
+ def coverage_row_is_current(
294
+ row: TorrentCoverageFile | None,
295
+ item,
296
+ path: Path,
297
+ piece_length: int,
298
+ ) -> bool:
299
+ if row is None or not path.exists() or not path.is_file() or path.is_symlink():
300
+ return False
301
+ stat = path.stat()
302
+ expected_size = require_metadata_size(item)
303
+ expected_pieces = (expected_size + piece_length - 1) // piece_length
304
+ if (
305
+ row.path != metadata_path(item)
306
+ or row.size != expected_size
307
+ or stat.st_size != expected_size
308
+ or row.mtime_ns != stat.st_mtime_ns
309
+ or len(row.v1_piece_hashes) != expected_pieces
310
+ or len(row.v2_piece_hashes) != expected_pieces
311
+ or (expected_size > 0 and row.v2_file_root is None)
312
+ or (expected_size == 0 and row.v2_file_root is not None)
313
+ ):
314
+ return False
315
+ lfs_sha256 = metadata_lfs_sha256(item)
316
+ blob_id = metadata_blob_id(item)
317
+ if lfs_sha256 is not None:
318
+ return row.sha256 == lfs_sha256 and row.lfs_sha256 == lfs_sha256
319
+ if blob_id is not None:
320
+ return row.git_blob_sha1 == blob_id and row.blob_id == blob_id
321
+ return False
322
+
323
+
324
+ def ensure_upstream_hashes(item, hashes: FileHashes) -> None:
325
+ rel = metadata_path(item)
326
+ lfs_sha256 = metadata_lfs_sha256(item)
327
+ if lfs_sha256 is not None:
328
+ if hashes.sha256 != lfs_sha256:
329
+ raise TorrentPublicationError(f"SHA-256 mismatch while recording torrent coverage for {rel}")
330
+ return
331
+ blob_id = metadata_blob_id(item)
332
+ if blob_id is not None:
333
+ if hashes.git_blob_sha1 != blob_id:
334
+ raise TorrentPublicationError(f"Git blob mismatch while recording torrent coverage for {rel}")
335
+ return
336
+ raise TorrentPublicationError(f"upstream content identity is missing for {rel}")
337
+
338
+
339
+ def upgrade_coverage(
340
+ root: Path,
341
+ snapshot,
342
+ *,
343
+ dry_run: bool = False,
344
+ on_progress: Callable[[str, int, int], None] | None = None,
345
+ ) -> CoverageUpgradeResult:
346
+ recorder = TorrentCoverageRecorder(root, snapshot)
347
+ missing = recorder.missing_paths()
348
+ if dry_run:
349
+ return CoverageUpgradeResult(
350
+ path=recorder.path,
351
+ total_files=len(recorder.expected),
352
+ covered_files=len(recorder.expected) - len(missing),
353
+ hashed_files=len(missing),
354
+ hashed_bytes=sum(require_metadata_size(recorder.expected[rel]) for rel in missing),
355
+ complete=not missing,
356
+ dry_run=True,
357
+ )
358
+
359
+ hashed_files = 0
360
+ hashed_bytes = 0
361
+ for rel in missing:
362
+ item = recorder.expected[rel]
363
+ path = root / rel
364
+ if not path.exists() or not path.is_file() or path.is_symlink():
365
+ raise TorrentPublicationError(f"cannot upgrade torrent coverage; payload file is missing: {rel}")
366
+ accumulator = recorder.accumulator(item)
367
+ total = require_metadata_size(item)
368
+ hashes = file_hashes(
369
+ path,
370
+ accumulators=(accumulator,),
371
+ on_progress=(
372
+ (lambda done, rel=rel, total=total: on_progress(rel, done, total))
373
+ if on_progress is not None
374
+ else None
375
+ ),
376
+ )
377
+ recorder.record(item, path, hashes, accumulator.finalize())
378
+ hashed_files += 1
379
+ hashed_bytes += total
380
+ return CoverageUpgradeResult(
381
+ path=recorder.path,
382
+ total_files=len(recorder.expected),
383
+ covered_files=len(recorder.expected) - len(recorder.missing_paths()),
384
+ hashed_files=hashed_files,
385
+ hashed_bytes=hashed_bytes,
386
+ complete=recorder.complete,
387
+ )
388
+
389
+
390
+ def coverage_hex(value: object, length: int, label: str, source: Path) -> str:
391
+ text = str(value).lower()
392
+ if len(text) != length or any(character not in "0123456789abcdef" for character in text):
393
+ raise ValueError(f"Invalid {label} in {source}")
394
+ return text
395
+
396
+
397
+ def optional_coverage_hex(value: object, length: int, label: str, source: Path) -> str | None:
398
+ if value in {None, ""}:
399
+ return None
400
+ return coverage_hex(value, length, label, source)
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from dataclasses import dataclass
5
+
6
+
7
+ BLOCK_LENGTH = 16 * 1024
8
+ ZERO_HASH = bytes(32)
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class TorrentFileHashes:
13
+ v1_piece_hashes: tuple[bytes, ...]
14
+ v2_piece_hashes: tuple[bytes, ...]
15
+ v2_file_root: bytes | None
16
+
17
+
18
+ class TorrentFileHasher:
19
+ """Accumulate hybrid v1/v2 hashes for one file in canonical file order."""
20
+
21
+ def __init__(self, *, expected_size: int, piece_length: int, pad_v1_tail: bool):
22
+ if expected_size < 0:
23
+ raise ValueError("expected size must not be negative")
24
+ if piece_length < BLOCK_LENGTH or piece_length & (piece_length - 1):
25
+ raise ValueError("piece length must be a power of two and at least 16 KiB")
26
+ self.expected_size = expected_size
27
+ self.piece_length = piece_length
28
+ self.pad_v1_tail = pad_v1_tail
29
+ self._blocks_per_piece = piece_length // BLOCK_LENGTH
30
+ self.reset()
31
+
32
+ def reset(self) -> None:
33
+ self._bytes_hashed = 0
34
+ self._v1_hasher = hashlib.sha1()
35
+ self._v1_piece_bytes = 0
36
+ self._v1_piece_hashes: list[bytes] = []
37
+ self._block_buffer = bytearray()
38
+ self._piece_leaves: list[bytes] = []
39
+ self._v2_piece_hashes: list[bytes] = []
40
+ self._result: TorrentFileHashes | None = None
41
+
42
+ @property
43
+ def bytes_hashed(self) -> int:
44
+ return self._bytes_hashed
45
+
46
+ def update(self, data: bytes) -> None:
47
+ if self._result is not None:
48
+ raise RuntimeError("torrent file hasher is already finalized")
49
+ if not data:
50
+ return
51
+ if self._bytes_hashed + len(data) > self.expected_size:
52
+ raise ValueError("torrent file hasher received more bytes than expected")
53
+ self._update_v1(data)
54
+ self._update_v2(data)
55
+ self._bytes_hashed += len(data)
56
+
57
+ def finalize(self) -> TorrentFileHashes:
58
+ if self._result is not None:
59
+ return self._result
60
+ if self._bytes_hashed != self.expected_size:
61
+ raise ValueError(
62
+ f"torrent file hasher expected {self.expected_size} bytes, got {self._bytes_hashed}"
63
+ )
64
+
65
+ if self._v1_piece_bytes:
66
+ if self.pad_v1_tail:
67
+ self._v1_hasher.update(bytes(self.piece_length - self._v1_piece_bytes))
68
+ self._v1_piece_hashes.append(self._v1_hasher.digest())
69
+
70
+ if self._block_buffer:
71
+ self._finish_block(bytes(self._block_buffer))
72
+ self._block_buffer.clear()
73
+ if self._piece_leaves:
74
+ target_blocks = (
75
+ next_power_of_two(len(self._piece_leaves))
76
+ if self.expected_size <= self.piece_length
77
+ else self._blocks_per_piece
78
+ )
79
+ self._v2_piece_hashes.append(
80
+ merkle_root(self._piece_leaves, target_count=target_blocks, empty_hash=ZERO_HASH)
81
+ )
82
+ self._piece_leaves.clear()
83
+
84
+ file_root = self._file_root()
85
+ self._result = TorrentFileHashes(
86
+ v1_piece_hashes=tuple(self._v1_piece_hashes),
87
+ v2_piece_hashes=tuple(self._v2_piece_hashes),
88
+ v2_file_root=file_root,
89
+ )
90
+ return self._result
91
+
92
+ def _update_v1(self, data: bytes) -> None:
93
+ remaining = memoryview(data)
94
+ while remaining:
95
+ take = min(len(remaining), self.piece_length - self._v1_piece_bytes)
96
+ self._v1_hasher.update(remaining[:take])
97
+ self._v1_piece_bytes += take
98
+ remaining = remaining[take:]
99
+ if self._v1_piece_bytes == self.piece_length:
100
+ self._v1_piece_hashes.append(self._v1_hasher.digest())
101
+ self._v1_hasher = hashlib.sha1()
102
+ self._v1_piece_bytes = 0
103
+
104
+ def _update_v2(self, data: bytes) -> None:
105
+ remaining = memoryview(data)
106
+ while remaining:
107
+ take = min(len(remaining), BLOCK_LENGTH - len(self._block_buffer))
108
+ self._block_buffer.extend(remaining[:take])
109
+ remaining = remaining[take:]
110
+ if len(self._block_buffer) == BLOCK_LENGTH:
111
+ self._finish_block(bytes(self._block_buffer))
112
+ self._block_buffer.clear()
113
+
114
+ def _finish_block(self, block: bytes) -> None:
115
+ self._piece_leaves.append(hashlib.sha256(block).digest())
116
+ if len(self._piece_leaves) == self._blocks_per_piece:
117
+ self._v2_piece_hashes.append(
118
+ merkle_root(
119
+ self._piece_leaves,
120
+ target_count=self._blocks_per_piece,
121
+ empty_hash=ZERO_HASH,
122
+ )
123
+ )
124
+ self._piece_leaves.clear()
125
+
126
+ def _file_root(self) -> bytes | None:
127
+ if not self._v2_piece_hashes:
128
+ return None
129
+ if self.expected_size <= self.piece_length:
130
+ return self._v2_piece_hashes[0]
131
+ zero_piece_root = zero_subtree_root(self._blocks_per_piece)
132
+ return merkle_root(
133
+ self._v2_piece_hashes,
134
+ target_count=next_power_of_two(len(self._v2_piece_hashes)),
135
+ empty_hash=zero_piece_root,
136
+ )
137
+
138
+
139
+ def next_power_of_two(value: int) -> int:
140
+ if value < 1:
141
+ raise ValueError("value must be positive")
142
+ return 1 << (value - 1).bit_length()
143
+
144
+
145
+ def zero_subtree_root(leaves: int) -> bytes:
146
+ if leaves < 1 or leaves & (leaves - 1):
147
+ raise ValueError("leaf count must be a positive power of two")
148
+ root = ZERO_HASH
149
+ remaining = leaves
150
+ while remaining > 1:
151
+ root = hashlib.sha256(root + root).digest()
152
+ remaining //= 2
153
+ return root
154
+
155
+
156
+ def merkle_root(hashes: list[bytes], *, target_count: int, empty_hash: bytes) -> bytes:
157
+ if not hashes:
158
+ raise ValueError("at least one hash is required")
159
+ if target_count < len(hashes) or target_count & (target_count - 1):
160
+ raise ValueError("target count must be a power of two covering all hashes")
161
+ level = list(hashes)
162
+ level.extend(empty_hash for _ in range(target_count - len(level)))
163
+ while len(level) > 1:
164
+ level = [
165
+ hashlib.sha256(level[index] + level[index + 1]).digest()
166
+ for index in range(0, len(level), 2)
167
+ ]
168
+ return level[0]