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,620 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path, PurePosixPath
|
|
9
|
+
|
|
10
|
+
from .checksums import FileHashes, file_hashes, write_manifest
|
|
11
|
+
from .config import Config, archive_path, safe_repo_path
|
|
12
|
+
from .hub import HubFile, HubSnapshot, write_snapshot_plan
|
|
13
|
+
from .state import VerificationState, utc_now, write_verification_state
|
|
14
|
+
from .torrent import (
|
|
15
|
+
DESCRIPTOR_INFO_KEY,
|
|
16
|
+
DESCRIPTOR_SCHEMA,
|
|
17
|
+
DESCRIPTOR_VERSION,
|
|
18
|
+
PUBLICATION_PROFILE,
|
|
19
|
+
SAFE_COMMIT,
|
|
20
|
+
HybridMetainfo,
|
|
21
|
+
PublicationDescriptor,
|
|
22
|
+
TorrentPayloadFile,
|
|
23
|
+
TorrentPublicationError,
|
|
24
|
+
hybrid_metainfo_from_tree,
|
|
25
|
+
hybrid_v1_files,
|
|
26
|
+
load_libtorrent,
|
|
27
|
+
require_hex,
|
|
28
|
+
select_piece_length,
|
|
29
|
+
validate_payload_path,
|
|
30
|
+
)
|
|
31
|
+
from .torrent_coverage import (
|
|
32
|
+
TorrentCoverage,
|
|
33
|
+
TorrentCoverageFile,
|
|
34
|
+
TorrentCoverageRecorder,
|
|
35
|
+
coverage_path,
|
|
36
|
+
write_coverage,
|
|
37
|
+
)
|
|
38
|
+
from .torrent_publication import (
|
|
39
|
+
FENCE_SCHEMA,
|
|
40
|
+
FENCE_VERSION,
|
|
41
|
+
PublicationRecord,
|
|
42
|
+
fence_path,
|
|
43
|
+
publication_dir,
|
|
44
|
+
publication_record_path,
|
|
45
|
+
recovery_value,
|
|
46
|
+
write_bytes_atomic,
|
|
47
|
+
write_json_atomic,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
MAX_TORRENT_FILES = 1_000_000
|
|
52
|
+
MAX_TORRENT_PAYLOAD_BYTES = 1 << 60
|
|
53
|
+
IMPORT_SCHEMA = "model-mirror-torrent-import"
|
|
54
|
+
IMPORT_VERSION = 1
|
|
55
|
+
STAGING_DIR = ".torrent-staging"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class ParsedPublication:
|
|
60
|
+
descriptor: PublicationDescriptor
|
|
61
|
+
artifact: HybridMetainfo
|
|
62
|
+
metainfo_tree: dict[bytes, object]
|
|
63
|
+
root_name: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class ImportResult:
|
|
68
|
+
path: Path
|
|
69
|
+
publication: PublicationRecord
|
|
70
|
+
reread_files: int
|
|
71
|
+
reread_bytes: int
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_publication_metainfo(
|
|
75
|
+
metainfo: bytes,
|
|
76
|
+
*,
|
|
77
|
+
libtorrent_module=None,
|
|
78
|
+
) -> ParsedPublication:
|
|
79
|
+
lt = libtorrent_module or load_libtorrent()
|
|
80
|
+
try:
|
|
81
|
+
tree = lt.bdecode(metainfo)
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
raise TorrentPublicationError("torrent metainfo is malformed") from exc
|
|
84
|
+
if not isinstance(tree, dict) or not isinstance(tree.get(b"info"), dict):
|
|
85
|
+
raise TorrentPublicationError("torrent metainfo has no info dictionary")
|
|
86
|
+
info = tree[b"info"]
|
|
87
|
+
raw_descriptor = info.get(DESCRIPTOR_INFO_KEY)
|
|
88
|
+
if not isinstance(raw_descriptor, dict):
|
|
89
|
+
raise TorrentPublicationError("torrent does not contain a model-mirror publication descriptor")
|
|
90
|
+
descriptor = parse_descriptor(raw_descriptor)
|
|
91
|
+
if raw_descriptor != descriptor.bencode_value():
|
|
92
|
+
raise TorrentPublicationError("publication descriptor is non-canonical or contains unknown fields")
|
|
93
|
+
if info.get(b"piece length") != descriptor.piece_length:
|
|
94
|
+
raise TorrentPublicationError("torrent piece length does not match its publication descriptor")
|
|
95
|
+
if info.get(b"meta version") != 2:
|
|
96
|
+
raise TorrentPublicationError("publication is not a BitTorrent v2/hybrid torrent")
|
|
97
|
+
try:
|
|
98
|
+
root_name = bytes_text(info[b"name"], "torrent root name")
|
|
99
|
+
except KeyError as exc:
|
|
100
|
+
raise TorrentPublicationError("torrent root name is missing") from exc
|
|
101
|
+
expected_root = safe_repo_path(descriptor.repo_id).name
|
|
102
|
+
if root_name != expected_root:
|
|
103
|
+
raise TorrentPublicationError(
|
|
104
|
+
f"torrent root name does not match repository identity: {root_name!r} != {expected_root!r}"
|
|
105
|
+
)
|
|
106
|
+
if info.get(b"files") != hybrid_v1_files(descriptor):
|
|
107
|
+
raise TorrentPublicationError("torrent payload layout does not match the publication descriptor")
|
|
108
|
+
validate_v2_file_tree(info.get(b"file tree"), descriptor)
|
|
109
|
+
artifact = hybrid_metainfo_from_tree(tree, descriptor, libtorrent_module=lt)
|
|
110
|
+
if artifact.metainfo != metainfo:
|
|
111
|
+
# bencode canonicalization is part of the profile and protects deterministic handoff.
|
|
112
|
+
raise TorrentPublicationError("torrent metainfo is not canonically bencoded")
|
|
113
|
+
return ParsedPublication(descriptor, artifact, tree, root_name)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse_descriptor(value: dict[bytes, object]) -> PublicationDescriptor:
|
|
117
|
+
try:
|
|
118
|
+
if bytes_text(value[b"schema"], "descriptor schema") != DESCRIPTOR_SCHEMA:
|
|
119
|
+
raise TorrentPublicationError("unsupported publication descriptor schema")
|
|
120
|
+
if value[b"version"] != DESCRIPTOR_VERSION:
|
|
121
|
+
raise TorrentPublicationError("unsupported publication descriptor version")
|
|
122
|
+
if bytes_text(value[b"profile"], "publication profile") != PUBLICATION_PROFILE:
|
|
123
|
+
raise TorrentPublicationError("unsupported publication profile")
|
|
124
|
+
if bytes_text(value[b"provider"], "publication provider") != "huggingface":
|
|
125
|
+
raise TorrentPublicationError("unsupported publication provider")
|
|
126
|
+
repo_id = bytes_text(value[b"repo_id"], "repository id")
|
|
127
|
+
repo_type = bytes_text(value[b"repo_type"], "repository type")
|
|
128
|
+
resolved_commit = bytes_text(value[b"resolved_commit"], "resolved commit")
|
|
129
|
+
piece_length = int(value[b"piece_length"])
|
|
130
|
+
raw_files = value[b"files"]
|
|
131
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
132
|
+
raise TorrentPublicationError("malformed publication descriptor") from exc
|
|
133
|
+
safe_repo_path(repo_id)
|
|
134
|
+
if repo_type not in {"model", "dataset", "space"}:
|
|
135
|
+
raise TorrentPublicationError(f"unsupported repository type: {repo_type!r}")
|
|
136
|
+
if SAFE_COMMIT.fullmatch(resolved_commit) is None:
|
|
137
|
+
raise TorrentPublicationError(f"unsafe resolved commit: {resolved_commit!r}")
|
|
138
|
+
if not isinstance(raw_files, list) or not raw_files or len(raw_files) > MAX_TORRENT_FILES:
|
|
139
|
+
raise TorrentPublicationError("publication descriptor has an unreasonable file count")
|
|
140
|
+
|
|
141
|
+
files = []
|
|
142
|
+
seen = set()
|
|
143
|
+
total_size = 0
|
|
144
|
+
for raw in raw_files:
|
|
145
|
+
if not isinstance(raw, dict):
|
|
146
|
+
raise TorrentPublicationError("malformed publication descriptor file row")
|
|
147
|
+
allowed = {b"path", b"size", b"sha256", b"git_blob_sha1", b"lfs_sha256", b"blob_id"}
|
|
148
|
+
if set(raw) - allowed:
|
|
149
|
+
raise TorrentPublicationError("publication descriptor file row contains unknown fields")
|
|
150
|
+
try:
|
|
151
|
+
rel = validate_payload_path(bytes_text(raw[b"path"], "payload path"))
|
|
152
|
+
size = int(raw[b"size"])
|
|
153
|
+
sha256 = require_hex(bytes_text(raw[b"sha256"], "payload SHA-256"), 64, "payload SHA-256")
|
|
154
|
+
git_blob = require_hex(
|
|
155
|
+
bytes_text(raw[b"git_blob_sha1"], "payload Git blob SHA-1"),
|
|
156
|
+
40,
|
|
157
|
+
"payload Git blob SHA-1",
|
|
158
|
+
)
|
|
159
|
+
lfs = (
|
|
160
|
+
require_hex(bytes_text(raw[b"lfs_sha256"], "LFS SHA-256"), 64, "LFS SHA-256")
|
|
161
|
+
if b"lfs_sha256" in raw
|
|
162
|
+
else None
|
|
163
|
+
)
|
|
164
|
+
blob = (
|
|
165
|
+
require_hex(bytes_text(raw[b"blob_id"], "blob ID"), 40, "blob ID")
|
|
166
|
+
if b"blob_id" in raw
|
|
167
|
+
else None
|
|
168
|
+
)
|
|
169
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
170
|
+
raise TorrentPublicationError("malformed publication descriptor file row") from exc
|
|
171
|
+
if size < 0 or rel in seen or (lfs is None and blob is None):
|
|
172
|
+
raise TorrentPublicationError(f"invalid or duplicate payload entry: {rel}")
|
|
173
|
+
if lfs is not None and lfs != sha256:
|
|
174
|
+
raise TorrentPublicationError(f"LFS identity does not match full-file SHA-256: {rel}")
|
|
175
|
+
if lfs is None and blob != git_blob:
|
|
176
|
+
raise TorrentPublicationError(f"blob identity does not match full-file Git blob SHA-1: {rel}")
|
|
177
|
+
seen.add(rel)
|
|
178
|
+
total_size += size
|
|
179
|
+
if total_size > MAX_TORRENT_PAYLOAD_BYTES:
|
|
180
|
+
raise TorrentPublicationError("publication descriptor payload size is unreasonable")
|
|
181
|
+
files.append(TorrentPayloadFile(rel, size, sha256, git_blob, lfs, blob))
|
|
182
|
+
validate_path_collisions(seen)
|
|
183
|
+
if total_size < 1 or piece_length != select_piece_length(total_size):
|
|
184
|
+
raise TorrentPublicationError("publication descriptor has a non-canonical piece length")
|
|
185
|
+
return PublicationDescriptor(
|
|
186
|
+
repo_id=repo_id,
|
|
187
|
+
repo_type=repo_type,
|
|
188
|
+
resolved_commit=resolved_commit,
|
|
189
|
+
piece_length=piece_length,
|
|
190
|
+
files=tuple(files),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def validate_path_collisions(paths: set[str]) -> None:
|
|
195
|
+
for rel in paths:
|
|
196
|
+
parts = PurePosixPath(rel).parts
|
|
197
|
+
for length in range(1, len(parts)):
|
|
198
|
+
if PurePosixPath(*parts[:length]).as_posix() in paths:
|
|
199
|
+
raise TorrentPublicationError(f"payload file/directory collision: {rel}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def validate_v2_file_tree(value: object, descriptor: PublicationDescriptor) -> None:
|
|
203
|
+
if not isinstance(value, dict):
|
|
204
|
+
raise TorrentPublicationError("torrent v2 file tree is missing")
|
|
205
|
+
found: dict[str, dict[bytes, object]] = {}
|
|
206
|
+
|
|
207
|
+
def walk(node: dict, parts: tuple[str, ...]) -> None:
|
|
208
|
+
for raw_name, child in node.items():
|
|
209
|
+
if raw_name == b"" or not isinstance(child, dict):
|
|
210
|
+
raise TorrentPublicationError("malformed torrent v2 file tree")
|
|
211
|
+
name = bytes_text(raw_name, "v2 path component")
|
|
212
|
+
if b"" in child:
|
|
213
|
+
if set(child) != {b""} or not isinstance(child[b""], dict):
|
|
214
|
+
raise TorrentPublicationError("malformed torrent v2 file leaf")
|
|
215
|
+
found[PurePosixPath(*parts, name).as_posix()] = child[b""]
|
|
216
|
+
else:
|
|
217
|
+
walk(child, (*parts, name))
|
|
218
|
+
|
|
219
|
+
walk(value, ())
|
|
220
|
+
if set(found) != {item.path for item in descriptor.files}:
|
|
221
|
+
raise TorrentPublicationError("torrent v2 file tree does not match the descriptor")
|
|
222
|
+
for item in descriptor.files:
|
|
223
|
+
leaf = found[item.path]
|
|
224
|
+
if leaf.get(b"length") != item.size or set(leaf) - {b"length", b"pieces root"}:
|
|
225
|
+
raise TorrentPublicationError(f"invalid v2 file leaf: {item.path}")
|
|
226
|
+
root = leaf.get(b"pieces root")
|
|
227
|
+
if item.size == 0:
|
|
228
|
+
if root is not None:
|
|
229
|
+
raise TorrentPublicationError(f"empty file has a v2 pieces root: {item.path}")
|
|
230
|
+
elif not isinstance(root, bytes) or len(root) != 32:
|
|
231
|
+
raise TorrentPublicationError(f"file has no valid v2 pieces root: {item.path}")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def bytes_text(value: object, label: str) -> str:
|
|
235
|
+
if not isinstance(value, bytes):
|
|
236
|
+
raise TorrentPublicationError(f"{label} must be a byte string")
|
|
237
|
+
try:
|
|
238
|
+
return value.decode("utf-8")
|
|
239
|
+
except UnicodeDecodeError as exc:
|
|
240
|
+
raise TorrentPublicationError(f"{label} is not valid UTF-8") from exc
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def external_handoff(config: Config, parsed: ParsedPublication, torrent_path: Path) -> tuple[Path, str]:
|
|
244
|
+
stage_parent = staging_parent(config, parsed.artifact.infohash_v2)
|
|
245
|
+
payload_root = stage_parent / parsed.root_name
|
|
246
|
+
command = (
|
|
247
|
+
f"model-mirror torrent import {shell_quote(str(torrent_path))} "
|
|
248
|
+
f"{shell_quote(str(payload_root))}"
|
|
249
|
+
)
|
|
250
|
+
return stage_parent, command
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def staging_parent(config: Config, infohash_v2: str) -> Path:
|
|
254
|
+
return Path(config.directory) / STAGING_DIR / infohash_v2
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def import_external_payload(
|
|
258
|
+
config: Config,
|
|
259
|
+
*,
|
|
260
|
+
metainfo: bytes,
|
|
261
|
+
payload_root: Path,
|
|
262
|
+
seed: bool = False,
|
|
263
|
+
backend_verified: bool = False,
|
|
264
|
+
libtorrent_module=None,
|
|
265
|
+
) -> ImportResult:
|
|
266
|
+
parsed = parse_publication_metainfo(metainfo, libtorrent_module=libtorrent_module)
|
|
267
|
+
return finalize_payload(
|
|
268
|
+
config,
|
|
269
|
+
parsed,
|
|
270
|
+
payload_root,
|
|
271
|
+
seed=seed,
|
|
272
|
+
backend_verified=backend_verified,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def finalize_payload(
|
|
277
|
+
config: Config,
|
|
278
|
+
parsed: ParsedPublication,
|
|
279
|
+
payload_root: Path,
|
|
280
|
+
*,
|
|
281
|
+
seed: bool,
|
|
282
|
+
backend_verified: bool,
|
|
283
|
+
) -> ImportResult:
|
|
284
|
+
descriptor = parsed.descriptor
|
|
285
|
+
target = archive_path(config, descriptor.repo_id, descriptor.repo_type)
|
|
286
|
+
if payload_root.name != parsed.root_name:
|
|
287
|
+
raise TorrentPublicationError(
|
|
288
|
+
f"downloaded payload root must be named {parsed.root_name!r}: {payload_root}"
|
|
289
|
+
)
|
|
290
|
+
if target.exists():
|
|
291
|
+
raise TorrentPublicationError(f"canonical archive target already exists: {target}")
|
|
292
|
+
validate_staged_payload(payload_root, descriptor)
|
|
293
|
+
snapshot = descriptor_snapshot(descriptor)
|
|
294
|
+
write_snapshot_plan(payload_root, snapshot)
|
|
295
|
+
|
|
296
|
+
if backend_verified:
|
|
297
|
+
coverage = coverage_from_metainfo(payload_root, parsed)
|
|
298
|
+
write_coverage(coverage_path(payload_root, descriptor.resolved_commit), coverage)
|
|
299
|
+
reread_files = 0
|
|
300
|
+
reread_bytes = 0
|
|
301
|
+
else:
|
|
302
|
+
recorder = TorrentCoverageRecorder(payload_root, snapshot)
|
|
303
|
+
for item in snapshot.files:
|
|
304
|
+
path = payload_root / item.path
|
|
305
|
+
accumulator = recorder.accumulator(item)
|
|
306
|
+
hashes = file_hashes(path, accumulators=(accumulator,))
|
|
307
|
+
recorder.record(item, path, hashes, accumulator.finalize())
|
|
308
|
+
coverage = recorder.coverage
|
|
309
|
+
reread_files = len(descriptor.files)
|
|
310
|
+
reread_bytes = descriptor.total_size
|
|
311
|
+
|
|
312
|
+
write_descriptor_manifest(payload_root, descriptor)
|
|
313
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
314
|
+
if payload_root.stat().st_dev != target.parent.stat().st_dev:
|
|
315
|
+
raise TorrentPublicationError(
|
|
316
|
+
f"staging and archive target are on different filesystems; download under "
|
|
317
|
+
f"{Path(config.directory) / STAGING_DIR}"
|
|
318
|
+
)
|
|
319
|
+
payload_root.replace(target)
|
|
320
|
+
state = VerificationState(
|
|
321
|
+
status="torrent-verified",
|
|
322
|
+
repo_id=descriptor.repo_id,
|
|
323
|
+
repo_type=descriptor.repo_type,
|
|
324
|
+
requested_revision=descriptor.resolved_commit,
|
|
325
|
+
resolved_commit=descriptor.resolved_commit,
|
|
326
|
+
upstream_status="unknown",
|
|
327
|
+
issues=["torrent content verified; upstream provenance not independently verified"],
|
|
328
|
+
)
|
|
329
|
+
write_verification_state(target, state)
|
|
330
|
+
write_json_atomic(
|
|
331
|
+
target / ".model-mirror" / "torrent" / "import.json",
|
|
332
|
+
{
|
|
333
|
+
"schema": IMPORT_SCHEMA,
|
|
334
|
+
"version": IMPORT_VERSION,
|
|
335
|
+
"content_verification": "torrent-verified",
|
|
336
|
+
"publication_trust": "trusted-infohash",
|
|
337
|
+
"upstream_provenance": "not-upstream-verified",
|
|
338
|
+
"upstream_availability": "unknown",
|
|
339
|
+
"infohash_v1": parsed.artifact.infohash_v1,
|
|
340
|
+
"infohash_v2": parsed.artifact.infohash_v2,
|
|
341
|
+
"imported_at_utc": utc_now(),
|
|
342
|
+
},
|
|
343
|
+
)
|
|
344
|
+
record = register_imported_publication(target, parsed, coverage, desired_seed=seed)
|
|
345
|
+
return ImportResult(target, record, reread_files, reread_bytes)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def validate_staged_payload(root: Path, descriptor: PublicationDescriptor) -> None:
|
|
349
|
+
if not root.exists() or not root.is_dir() or root.is_symlink():
|
|
350
|
+
raise TorrentPublicationError(f"staged payload root is missing or unsafe: {root}")
|
|
351
|
+
remove_padding_files(root, descriptor)
|
|
352
|
+
expected = {item.path: item for item in descriptor.files}
|
|
353
|
+
found = set()
|
|
354
|
+
for path in sorted(root.rglob("*")):
|
|
355
|
+
if path.is_symlink():
|
|
356
|
+
raise TorrentPublicationError(f"staged payload contains a symlink: {path}")
|
|
357
|
+
if not path.is_file():
|
|
358
|
+
continue
|
|
359
|
+
rel = path.relative_to(root).as_posix()
|
|
360
|
+
if rel not in expected:
|
|
361
|
+
raise TorrentPublicationError(f"staged payload contains an unexpected file: {rel}")
|
|
362
|
+
if path.stat().st_size != expected[rel].size:
|
|
363
|
+
raise TorrentPublicationError(f"staged payload size mismatch: {rel}")
|
|
364
|
+
found.add(rel)
|
|
365
|
+
missing = sorted(set(expected) - found)
|
|
366
|
+
if missing:
|
|
367
|
+
raise TorrentPublicationError(f"staged payload is missing files: {missing!r}")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def remove_padding_files(root: Path, descriptor: PublicationDescriptor) -> None:
|
|
371
|
+
pad_root = root / ".pad"
|
|
372
|
+
if not pad_root.exists():
|
|
373
|
+
return
|
|
374
|
+
if not pad_root.is_dir() or pad_root.is_symlink():
|
|
375
|
+
raise TorrentPublicationError("torrent padding path is unsafe")
|
|
376
|
+
expected = {
|
|
377
|
+
row[b"path"][1].decode("ascii"): row[b"length"]
|
|
378
|
+
for row in hybrid_v1_files(descriptor)
|
|
379
|
+
if row.get(b"attr") == b"p"
|
|
380
|
+
}
|
|
381
|
+
for path in pad_root.rglob("*"):
|
|
382
|
+
if path.is_symlink() or (path.is_file() and (path.name not in expected or path.stat().st_size != expected[path.name])):
|
|
383
|
+
raise TorrentPublicationError(f"unexpected torrent padding file: {path}")
|
|
384
|
+
shutil.rmtree(pad_root)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def descriptor_snapshot(descriptor: PublicationDescriptor) -> HubSnapshot:
|
|
388
|
+
return HubSnapshot(
|
|
389
|
+
descriptor.repo_id,
|
|
390
|
+
descriptor.repo_type,
|
|
391
|
+
descriptor.resolved_commit,
|
|
392
|
+
descriptor.resolved_commit,
|
|
393
|
+
[
|
|
394
|
+
HubFile(item.path, item.size, lfs_sha256=item.lfs_sha256, blob_id=item.blob_id)
|
|
395
|
+
for item in descriptor.files
|
|
396
|
+
],
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def write_descriptor_manifest(root: Path, descriptor: PublicationDescriptor) -> None:
|
|
401
|
+
manifest = {}
|
|
402
|
+
for item in descriptor.files:
|
|
403
|
+
stat = (root / item.path).stat()
|
|
404
|
+
manifest[item.path] = {
|
|
405
|
+
"path": item.path,
|
|
406
|
+
"sha256": item.sha256,
|
|
407
|
+
"git_blob_sha1": item.git_blob_sha1,
|
|
408
|
+
"size": stat.st_size,
|
|
409
|
+
"mtime_ns": stat.st_mtime_ns,
|
|
410
|
+
}
|
|
411
|
+
write_manifest(root, manifest)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def coverage_from_metainfo(root: Path, parsed: ParsedPublication) -> TorrentCoverage:
|
|
415
|
+
descriptor = parsed.descriptor
|
|
416
|
+
info = parsed.metainfo_tree[b"info"]
|
|
417
|
+
pieces = info[b"pieces"]
|
|
418
|
+
if not isinstance(pieces, bytes) or len(pieces) % 20:
|
|
419
|
+
raise TorrentPublicationError("torrent v1 piece hashes are malformed")
|
|
420
|
+
v1_hashes = [pieces[offset : offset + 20] for offset in range(0, len(pieces), 20)]
|
|
421
|
+
piece_layers = parsed.metainfo_tree.get(b"piece layers", {})
|
|
422
|
+
if not isinstance(piece_layers, dict):
|
|
423
|
+
raise TorrentPublicationError("torrent v2 piece layers are malformed")
|
|
424
|
+
coverage = TorrentCoverage(
|
|
425
|
+
repo_id=descriptor.repo_id,
|
|
426
|
+
repo_type=descriptor.repo_type,
|
|
427
|
+
resolved_commit=descriptor.resolved_commit,
|
|
428
|
+
piece_length=descriptor.piece_length,
|
|
429
|
+
)
|
|
430
|
+
cursor = 0
|
|
431
|
+
for item in descriptor.files:
|
|
432
|
+
count = (item.size + descriptor.piece_length - 1) // descriptor.piece_length
|
|
433
|
+
selected_v1 = v1_hashes[cursor : cursor + count]
|
|
434
|
+
if len(selected_v1) != count:
|
|
435
|
+
raise TorrentPublicationError("torrent v1 piece hashes do not cover the descriptor")
|
|
436
|
+
cursor += count
|
|
437
|
+
root_hash = v2_file_root(info[b"file tree"], item.path)
|
|
438
|
+
if item.size == 0:
|
|
439
|
+
v2_hashes = []
|
|
440
|
+
elif item.size <= descriptor.piece_length:
|
|
441
|
+
v2_hashes = [root_hash]
|
|
442
|
+
else:
|
|
443
|
+
layer = piece_layers.get(root_hash)
|
|
444
|
+
if not isinstance(layer, bytes) or len(layer) != count * 32:
|
|
445
|
+
raise TorrentPublicationError(f"torrent v2 piece layer is incomplete: {item.path}")
|
|
446
|
+
v2_hashes = [layer[offset : offset + 32] for offset in range(0, len(layer), 32)]
|
|
447
|
+
stat = (root / item.path).stat()
|
|
448
|
+
coverage.files[item.path] = TorrentCoverageFile(
|
|
449
|
+
path=item.path,
|
|
450
|
+
size=item.size,
|
|
451
|
+
mtime_ns=stat.st_mtime_ns,
|
|
452
|
+
sha256=item.sha256,
|
|
453
|
+
git_blob_sha1=item.git_blob_sha1,
|
|
454
|
+
lfs_sha256=item.lfs_sha256,
|
|
455
|
+
blob_id=item.blob_id,
|
|
456
|
+
v1_piece_hashes=tuple(value.hex() for value in selected_v1),
|
|
457
|
+
v2_piece_hashes=tuple(value.hex() for value in v2_hashes),
|
|
458
|
+
v2_file_root=root_hash.hex() if root_hash is not None else None,
|
|
459
|
+
)
|
|
460
|
+
if cursor != len(v1_hashes):
|
|
461
|
+
raise TorrentPublicationError("torrent has unexpected extra v1 piece hashes")
|
|
462
|
+
return coverage
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def v2_file_root(tree: dict, rel: str) -> bytes | None:
|
|
466
|
+
node = tree
|
|
467
|
+
for part in PurePosixPath(rel).parts:
|
|
468
|
+
node = node[part.encode("utf-8")]
|
|
469
|
+
root = node[b""].get(b"pieces root")
|
|
470
|
+
return root
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def register_imported_publication(
|
|
474
|
+
root: Path,
|
|
475
|
+
parsed: ParsedPublication,
|
|
476
|
+
coverage: TorrentCoverage,
|
|
477
|
+
*,
|
|
478
|
+
desired_seed: bool,
|
|
479
|
+
) -> PublicationRecord:
|
|
480
|
+
descriptor = parsed.descriptor
|
|
481
|
+
target_dir = publication_dir(root, descriptor.resolved_commit, descriptor.profile)
|
|
482
|
+
torrent_path = target_dir / f"{root.name}@{descriptor.resolved_commit}.torrent"
|
|
483
|
+
recovery_path = target_dir / "recovery.json"
|
|
484
|
+
record_path = publication_record_path(root, descriptor.resolved_commit, descriptor.profile)
|
|
485
|
+
now = utc_now()
|
|
486
|
+
record = PublicationRecord(
|
|
487
|
+
repo_id=descriptor.repo_id,
|
|
488
|
+
repo_type=descriptor.repo_type,
|
|
489
|
+
resolved_commit=descriptor.resolved_commit,
|
|
490
|
+
profile=descriptor.profile,
|
|
491
|
+
descriptor_sha256=parsed.artifact.descriptor_sha256,
|
|
492
|
+
metainfo_sha256=parsed.artifact.metainfo_sha256,
|
|
493
|
+
infohash_v1=parsed.artifact.infohash_v1,
|
|
494
|
+
infohash_v2=parsed.artifact.infohash_v2,
|
|
495
|
+
magnet_uri=parsed.artifact.magnet_uri,
|
|
496
|
+
torrent_path=torrent_path.relative_to(root).as_posix(),
|
|
497
|
+
recovery_path=recovery_path.relative_to(root).as_posix(),
|
|
498
|
+
payload_fingerprints={
|
|
499
|
+
rel: {"size": row.size, "mtime_ns": row.mtime_ns}
|
|
500
|
+
for rel, row in coverage.files.items()
|
|
501
|
+
},
|
|
502
|
+
desired_seed=desired_seed,
|
|
503
|
+
observed_backend="pending" if desired_seed else "stopped",
|
|
504
|
+
content_verification="torrent-verified",
|
|
505
|
+
publication_trust="trusted-infohash",
|
|
506
|
+
upstream_provenance="not-upstream-verified",
|
|
507
|
+
upstream_availability="unknown",
|
|
508
|
+
created_at_utc=now,
|
|
509
|
+
updated_at_utc=now,
|
|
510
|
+
)
|
|
511
|
+
write_bytes_atomic(torrent_path, parsed.artifact.metainfo)
|
|
512
|
+
write_json_atomic(recovery_path, recovery_value(record))
|
|
513
|
+
write_json_atomic(record_path, record.to_dict())
|
|
514
|
+
write_json_atomic(
|
|
515
|
+
fence_path(root),
|
|
516
|
+
{
|
|
517
|
+
"schema": FENCE_SCHEMA,
|
|
518
|
+
"version": FENCE_VERSION,
|
|
519
|
+
"publication_id": record.publication_id,
|
|
520
|
+
"publication_record": record_path.relative_to(root).as_posix(),
|
|
521
|
+
},
|
|
522
|
+
)
|
|
523
|
+
return record
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def join_torrent(
|
|
527
|
+
config: Config,
|
|
528
|
+
source: str,
|
|
529
|
+
*,
|
|
530
|
+
seed: bool = False,
|
|
531
|
+
metadata_timeout_seconds: float = 120.0,
|
|
532
|
+
poll_seconds: float = 0.5,
|
|
533
|
+
libtorrent_module=None,
|
|
534
|
+
session=None,
|
|
535
|
+
on_progress=None,
|
|
536
|
+
) -> ImportResult:
|
|
537
|
+
if metadata_timeout_seconds <= 0 or poll_seconds <= 0:
|
|
538
|
+
raise ValueError("join timeouts must be positive")
|
|
539
|
+
lt = libtorrent_module or load_libtorrent()
|
|
540
|
+
session = session or lt.session()
|
|
541
|
+
source_path = Path(source)
|
|
542
|
+
if source_path.is_file():
|
|
543
|
+
metainfo = source_path.read_bytes()
|
|
544
|
+
parsed = parse_publication_metainfo(metainfo, libtorrent_module=lt)
|
|
545
|
+
params = lt.add_torrent_params()
|
|
546
|
+
params.ti = lt.torrent_info(metainfo)
|
|
547
|
+
stage_parent = staging_parent(config, parsed.artifact.infohash_v2)
|
|
548
|
+
elif source.startswith("magnet:?"):
|
|
549
|
+
params = lt.parse_magnet_uri(source)
|
|
550
|
+
stage_parent = Path(config.directory) / STAGING_DIR / magnet_staging_key(params, source)
|
|
551
|
+
parsed = None
|
|
552
|
+
metainfo = None
|
|
553
|
+
else:
|
|
554
|
+
raise TorrentPublicationError(f"torrent source is not a file or magnet URI: {source}")
|
|
555
|
+
stage_parent.mkdir(parents=True, exist_ok=True)
|
|
556
|
+
params.save_path = str(stage_parent)
|
|
557
|
+
handle = session.add_torrent(params)
|
|
558
|
+
started = time.monotonic()
|
|
559
|
+
last_report = -1
|
|
560
|
+
while parsed is None:
|
|
561
|
+
status = handle.status()
|
|
562
|
+
if status.has_metadata:
|
|
563
|
+
metainfo = metainfo_from_handle(handle, lt)
|
|
564
|
+
parsed = parse_publication_metainfo(metainfo, libtorrent_module=lt)
|
|
565
|
+
break
|
|
566
|
+
if status.error:
|
|
567
|
+
raise TorrentPublicationError(f"torrent metadata acquisition failed: {status.error}")
|
|
568
|
+
if time.monotonic() - started >= metadata_timeout_seconds:
|
|
569
|
+
raise TorrentPublicationError("timed out waiting for torrent metadata")
|
|
570
|
+
time.sleep(poll_seconds)
|
|
571
|
+
|
|
572
|
+
while True:
|
|
573
|
+
status = handle.status()
|
|
574
|
+
progress = int(status.progress * 1000)
|
|
575
|
+
if on_progress is not None and progress != last_report:
|
|
576
|
+
on_progress(status)
|
|
577
|
+
last_report = progress
|
|
578
|
+
if status.error:
|
|
579
|
+
raise TorrentPublicationError(f"torrent download failed: {status.error}")
|
|
580
|
+
if status.is_seeding or status.is_finished:
|
|
581
|
+
break
|
|
582
|
+
time.sleep(poll_seconds)
|
|
583
|
+
session.remove_torrent(handle)
|
|
584
|
+
payload_root = stage_parent / parsed.root_name
|
|
585
|
+
return import_external_payload(
|
|
586
|
+
config,
|
|
587
|
+
metainfo=metainfo,
|
|
588
|
+
payload_root=payload_root,
|
|
589
|
+
seed=seed,
|
|
590
|
+
backend_verified=True,
|
|
591
|
+
libtorrent_module=lt,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def magnet_staging_key(params, source: str) -> str:
|
|
596
|
+
info_hashes = params.info_hashes
|
|
597
|
+
if info_hashes.has_v2():
|
|
598
|
+
return str(info_hashes.v2)
|
|
599
|
+
if info_hashes.has_v1():
|
|
600
|
+
return str(info_hashes.v1)
|
|
601
|
+
return hashlib.sha256(source.encode("utf-8")).hexdigest()
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def metainfo_from_handle(handle, lt) -> bytes:
|
|
605
|
+
creator = lt.create_torrent(handle.torrent_file())
|
|
606
|
+
tree = creator.generate()
|
|
607
|
+
tree.pop(b"creation date", None)
|
|
608
|
+
raw_info = tree.get(b"info")
|
|
609
|
+
if isinstance(raw_info, tuple):
|
|
610
|
+
raw_bytes = bytes(value % 256 for value in raw_info)
|
|
611
|
+
tree[b"info"] = lt.bdecode(raw_bytes)
|
|
612
|
+
if not isinstance(tree.get(b"info"), dict):
|
|
613
|
+
raise TorrentPublicationError("torrent backend returned malformed metadata")
|
|
614
|
+
return bytes(lt.bencode(tree))
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def shell_quote(value: str) -> str:
|
|
618
|
+
import shlex
|
|
619
|
+
|
|
620
|
+
return shlex.quote(value)
|