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,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)