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.
- model_mirror/__init__.py +7 -0
- model_mirror/audit.py +214 -0
- model_mirror/checksums.py +371 -0
- model_mirror/cli.py +2436 -0
- model_mirror/config.py +300 -0
- model_mirror/hub.py +901 -0
- model_mirror/lock.py +119 -0
- model_mirror/mirror.py +201 -0
- model_mirror/progress.py +260 -0
- model_mirror/removal.py +164 -0
- model_mirror/repair.py +399 -0
- model_mirror/state.py +150 -0
- model_mirror/torrent.py +453 -0
- model_mirror/torrent_coverage.py +400 -0
- model_mirror/torrent_hashes.py +168 -0
- model_mirror/torrent_import.py +620 -0
- model_mirror/torrent_publication.py +582 -0
- model_mirror/torrent_seed.py +228 -0
- model_mirror/verify.py +153 -0
- model_mirror_cli-0.2.0.dist-info/METADATA +550 -0
- model_mirror_cli-0.2.0.dist-info/RECORD +24 -0
- model_mirror_cli-0.2.0.dist-info/WHEEL +4 -0
- model_mirror_cli-0.2.0.dist-info/entry_points.txt +2 -0
- model_mirror_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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]
|