llmt-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.
- llmt/__init__.py +7 -0
- llmt/api.py +138 -0
- llmt/cli.py +562 -0
- llmt/clients.py +1316 -0
- llmt/data/manifest-pubkey.json +7 -0
- llmt/doctor.py +970 -0
- llmt/doctorcmd.py +142 -0
- llmt/download.py +321 -0
- llmt/errors.py +132 -0
- llmt/layout.py +676 -0
- llmt/manifest.py +314 -0
- llmt/matcher.py +358 -0
- llmt/models.py +130 -0
- llmt/output.py +51 -0
- llmt/permissions.py +154 -0
- llmt/scancmd.py +556 -0
- llmt/scanner.py +328 -0
- llmt/telemetry/__init__.py +15 -0
- llmt/telemetry/artifacts.py +217 -0
- llmt/telemetry/beat.py +221 -0
- llmt/telemetry/cmd.py +358 -0
- llmt/telemetry/collect.py +252 -0
- llmt/telemetry/config.py +237 -0
- llmt/telemetry/consent.py +57 -0
- llmt/telemetry/errors.py +41 -0
- llmt/telemetry/wire.py +194 -0
- llmt/verify.py +129 -0
- llmt_cli-0.2.0.dist-info/METADATA +307 -0
- llmt_cli-0.2.0.dist-info/RECORD +33 -0
- llmt_cli-0.2.0.dist-info/WHEEL +5 -0
- llmt_cli-0.2.0.dist-info/entry_points.txt +2 -0
- llmt_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- llmt_cli-0.2.0.dist-info/top_level.txt +1 -0
llmt/doctorcmd.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""``llmt doctor`` — the seed doctor command (WP-14.4b).
|
|
2
|
+
|
|
3
|
+
Boring, practical output: one PASS / WARN / FAIL / N-A line per check with a
|
|
4
|
+
one-line fix hint on every non-PASS. ``--json`` emits the same results as a
|
|
5
|
+
machine-readable object and never prompts or reads stdin (doctor never
|
|
6
|
+
prompts at all — it is strictly read-only diagnosis).
|
|
7
|
+
|
|
8
|
+
Exit codes: 0 = no FAIL found (warnings may exist), 15 = at least one FAIL,
|
|
9
|
+
plus the usual LlmtError codes for expected errors (e.g. bad flags).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from . import doctor
|
|
17
|
+
from .clients import (
|
|
18
|
+
QBittorrentClient,
|
|
19
|
+
TransmissionClient,
|
|
20
|
+
build_client,
|
|
21
|
+
)
|
|
22
|
+
from .output import emit_json, safe_text
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# --------------------------------------------------------------------------- #
|
|
26
|
+
# argparse wiring (called from cli.build_parser) — same client flags as scan
|
|
27
|
+
# --------------------------------------------------------------------------- #
|
|
28
|
+
|
|
29
|
+
def add_doctor_args(sp) -> None:
|
|
30
|
+
sp.add_argument("--client", choices=["qbittorrent", "transmission"],
|
|
31
|
+
default=None,
|
|
32
|
+
help="Torrent client to diagnose. Default: auto "
|
|
33
|
+
"(whichever of --qb-url/--tr-url or LLMT_QB_URL/"
|
|
34
|
+
"LLMT_TR_URL is configured).")
|
|
35
|
+
sp.add_argument("--qb-url", default=None, help="qBittorrent WebUI base "
|
|
36
|
+
"URL (env LLMT_QB_URL).")
|
|
37
|
+
sp.add_argument("--qb-user", default=None, help="qBittorrent user (env "
|
|
38
|
+
"LLMT_QB_USER).")
|
|
39
|
+
sp.add_argument("--qb-pass", default=None, help="qBittorrent password "
|
|
40
|
+
"(env LLMT_QB_PASS).")
|
|
41
|
+
sp.add_argument("--tr-url", default=None, help="Transmission RPC URL "
|
|
42
|
+
"(env LLMT_TR_URL).")
|
|
43
|
+
sp.add_argument("--tr-user", default=None, help="Transmission user (env "
|
|
44
|
+
"LLMT_TR_USER).")
|
|
45
|
+
sp.add_argument("--tr-pass", default=None, help="Transmission password "
|
|
46
|
+
"(env LLMT_TR_PASS).")
|
|
47
|
+
sp.add_argument("--hash", action="append", default=[],
|
|
48
|
+
help="Only examine the torrent with this info-hash "
|
|
49
|
+
"(repeatable). Default: every torrent in the "
|
|
50
|
+
"client.")
|
|
51
|
+
sp.add_argument("--layout-base", default=None,
|
|
52
|
+
help="Root of llmt's seeding link farms "
|
|
53
|
+
"(default ~/.llmt/seeding-layouts) — used to "
|
|
54
|
+
"recognize llmt-planned layouts and to check the "
|
|
55
|
+
"farm is visible from the client's filesystem.")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --------------------------------------------------------------------------- #
|
|
59
|
+
# source selection (read-only wrappers over the frozen adapters)
|
|
60
|
+
# --------------------------------------------------------------------------- #
|
|
61
|
+
|
|
62
|
+
def build_source(args) -> doctor.DoctorSource:
|
|
63
|
+
"""Reuse the frozen ``build_client`` flag/env resolution, then wrap the
|
|
64
|
+
resulting adapter in a read-only doctor source. No client configured is
|
|
65
|
+
NOT an error here — it becomes a FAIL on the client-reachable check."""
|
|
66
|
+
client = build_client(args)
|
|
67
|
+
if isinstance(client, QBittorrentClient):
|
|
68
|
+
return doctor.QbtDoctorSource(client)
|
|
69
|
+
if isinstance(client, TransmissionClient):
|
|
70
|
+
return doctor.TransmissionDoctorSource(client)
|
|
71
|
+
return doctor.NullDoctorSource()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --------------------------------------------------------------------------- #
|
|
75
|
+
# command
|
|
76
|
+
# --------------------------------------------------------------------------- #
|
|
77
|
+
|
|
78
|
+
def cmd_doctor(args) -> int:
|
|
79
|
+
return run_doctor(args)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run_doctor(args, *, source: Optional[doctor.DoctorSource] = None) -> int:
|
|
83
|
+
as_json = getattr(args, "json", False)
|
|
84
|
+
source = source or build_source(args)
|
|
85
|
+
hashes = {h.strip().lower() for h in (getattr(args, "hash", None) or [])
|
|
86
|
+
if h.strip()} or None
|
|
87
|
+
|
|
88
|
+
snap = source.snapshot(hashes=hashes)
|
|
89
|
+
results = doctor.run_checks(snap, layout_base=args.layout_base)
|
|
90
|
+
summary = doctor.summarize(results)
|
|
91
|
+
|
|
92
|
+
if as_json:
|
|
93
|
+
emit_json({
|
|
94
|
+
"client": {
|
|
95
|
+
"kind": snap.kind,
|
|
96
|
+
"endpoint": snap.endpoint,
|
|
97
|
+
"reachable": snap.reachable,
|
|
98
|
+
"version": snap.version,
|
|
99
|
+
},
|
|
100
|
+
"torrents_examined": len(snap.torrents),
|
|
101
|
+
"checks": [r.to_dict() for r in results],
|
|
102
|
+
"summary": summary,
|
|
103
|
+
"ok": summary["ok"],
|
|
104
|
+
})
|
|
105
|
+
else:
|
|
106
|
+
_print_report(snap, results, summary, hashes)
|
|
107
|
+
|
|
108
|
+
return 0 if summary["ok"] else doctor.EXIT_DOCTOR_FAIL
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _print_report(snap, results, summary, hashes) -> None:
|
|
112
|
+
print(f"llmt doctor — {snap.kind} at {safe_text(snap.endpoint)}")
|
|
113
|
+
print("(signals are the client's own self-reported view unless a check "
|
|
114
|
+
"says otherwise)")
|
|
115
|
+
client_rows = [r for r in results if r.scope == "client"]
|
|
116
|
+
torrent_rows = [r for r in results if r.scope != "client"]
|
|
117
|
+
|
|
118
|
+
print("\nClient:")
|
|
119
|
+
for r in client_rows:
|
|
120
|
+
_print_row(r)
|
|
121
|
+
|
|
122
|
+
if snap.reachable and not snap.torrents:
|
|
123
|
+
print("\nNo torrents "
|
|
124
|
+
+ ("matched the --hash filter." if hashes
|
|
125
|
+
else "found in the client."))
|
|
126
|
+
scope = None
|
|
127
|
+
for r in torrent_rows:
|
|
128
|
+
if r.scope != scope:
|
|
129
|
+
scope = r.scope
|
|
130
|
+
print(f"\nTorrent: {safe_text(scope)}")
|
|
131
|
+
_print_row(r)
|
|
132
|
+
|
|
133
|
+
verdict = ("OK — no failures found"
|
|
134
|
+
if summary["ok"] else "PROBLEMS FOUND")
|
|
135
|
+
print(f"\nSummary: {summary['pass']} pass, {summary['warn']} warn, "
|
|
136
|
+
f"{summary['fail']} fail, {summary['na']} n/a -> {verdict}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _print_row(r) -> None:
|
|
140
|
+
print(f" [{r.status}] {r.check}: {safe_text(r.detail)}")
|
|
141
|
+
if r.status != doctor.PASS and r.hint:
|
|
142
|
+
print(f" fix: {safe_text(r.hint)}")
|
llmt/download.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""aria2c invocation builder + runner for torrent+webseed downloads.
|
|
2
|
+
|
|
3
|
+
We delegate the actual BitTorrent transfer to ``aria2c``, which natively
|
|
4
|
+
supports magnet URIs, extra BT trackers (``--bt-tracker``), and HTTP/FTP
|
|
5
|
+
webseeds. The webseeds are the whole point: they let a brand-new download
|
|
6
|
+
saturate bandwidth from the project's HTTP mirrors even before a peer appears
|
|
7
|
+
in the swarm.
|
|
8
|
+
|
|
9
|
+
How webseeds reach aria2c
|
|
10
|
+
-------------------------
|
|
11
|
+
For a magnet download aria2c fetches the metadata, then downloads the torrent's
|
|
12
|
+
files. Any extra ``http(s)://`` URIs passed on the same invocation are attached
|
|
13
|
+
as **web-seeds** (BEP-19 "GetRight" style) and used as HTTP mirrors for the
|
|
14
|
+
torrent payload alongside BT peers. So the argv is::
|
|
15
|
+
|
|
16
|
+
aria2c --dir <out> [--bt-tracker <t> ...] <magnet> <webseed_url> ...
|
|
17
|
+
|
|
18
|
+
This module only *builds* the argv and shells out. It never parses secrets and
|
|
19
|
+
never fabricates a magnet — the magnet always comes from the API.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import base64
|
|
25
|
+
import glob
|
|
26
|
+
import hashlib
|
|
27
|
+
import os
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
import tempfile
|
|
31
|
+
from typing import Optional
|
|
32
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
33
|
+
|
|
34
|
+
from .errors import MissingDependencyError
|
|
35
|
+
from .clients import ClientError, validate_magnet
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ensure_aria2c() -> str:
|
|
39
|
+
exe = shutil.which("aria2c")
|
|
40
|
+
if not exe:
|
|
41
|
+
raise MissingDependencyError(
|
|
42
|
+
"aria2c is not installed. Install it (e.g. `apt install aria2`, "
|
|
43
|
+
"`brew install aria2`) and re-run. llmt delegates the torrent + "
|
|
44
|
+
"webseed transfer to aria2c."
|
|
45
|
+
)
|
|
46
|
+
return exe
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate_webseed(url: object) -> str:
|
|
50
|
+
"""Return ``url`` iff it is a safe http(s) web-seed, else raise.
|
|
51
|
+
|
|
52
|
+
Anti-injection (C1): API-supplied webseeds are appended to the aria2c argv
|
|
53
|
+
as positional URIs. aria2c's getopt permutation would parse a token that
|
|
54
|
+
begins with ``-`` (e.g. ``--on-download-complete=...``) as an OPTION even
|
|
55
|
+
though it is passed in list-form argv, which is a remote-code-execution /
|
|
56
|
+
arbitrary-write vector. We therefore refuse anything that is not a bare
|
|
57
|
+
``http(s)://`` URL: reject option-looking tokens, whitespace/control
|
|
58
|
+
chars, non-http(s) schemes, and embedded userinfo credentials.
|
|
59
|
+
"""
|
|
60
|
+
if not isinstance(url, str) or not url:
|
|
61
|
+
raise ClientError("refusing an empty/invalid webseed from the API.")
|
|
62
|
+
if url.startswith("-"):
|
|
63
|
+
raise ClientError(
|
|
64
|
+
"refusing a webseed that looks like a command-line option "
|
|
65
|
+
"(anti-injection).")
|
|
66
|
+
if any(c in url for c in (" ", "\n", "\r", "\t", "\x00")):
|
|
67
|
+
raise ClientError(
|
|
68
|
+
"refusing a webseed containing whitespace/control chars.")
|
|
69
|
+
parsed = urlsplit(url)
|
|
70
|
+
if parsed.scheme not in ("http", "https"):
|
|
71
|
+
raise ClientError(
|
|
72
|
+
"refusing a webseed with a non-http(s) scheme (anti-injection).")
|
|
73
|
+
if "@" in parsed.netloc:
|
|
74
|
+
raise ClientError(
|
|
75
|
+
"refusing a webseed whose URL carries userinfo credentials "
|
|
76
|
+
"(credential-smuggling defense).")
|
|
77
|
+
return url
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_aria2c_args(
|
|
81
|
+
magnet: str,
|
|
82
|
+
out_dir: str,
|
|
83
|
+
webseeds: Optional[list[str]] = None,
|
|
84
|
+
trackers: Optional[list[str]] = None,
|
|
85
|
+
seed: bool = False,
|
|
86
|
+
exe: str = "aria2c",
|
|
87
|
+
metadata_only: bool = False,
|
|
88
|
+
) -> list[str]:
|
|
89
|
+
"""Construct the exact aria2c argv for a webseed-backed torrent download.
|
|
90
|
+
|
|
91
|
+
- ``magnet`` : API-supplied magnet URI (never fabricated).
|
|
92
|
+
- ``webseeds`` : HTTP mirrors, appended as trailing URIs so aria2c uses
|
|
93
|
+
them as web-seeds for the torrent's files.
|
|
94
|
+
- ``trackers`` : extra BT trackers, each via ``--bt-tracker``.
|
|
95
|
+
- ``seed`` : when True, keep seeding after completion; when False,
|
|
96
|
+
exit as soon as the download finishes.
|
|
97
|
+
- ``metadata_only`` : resolve the magnet to its ``.torrent`` (BEP-9
|
|
98
|
+
metadata) and exit WITHOUT downloading payload; used to
|
|
99
|
+
hand a client a ``.torrent`` instead of a magnet.
|
|
100
|
+
"""
|
|
101
|
+
if metadata_only:
|
|
102
|
+
# Same hardened choke point as the download path: validate the magnet
|
|
103
|
+
# and put a literal ``--`` end-of-options separator before it. No
|
|
104
|
+
# webseeds (they serve payload, not metadata) and no payload/seed
|
|
105
|
+
# flags. aria2c saves ``<info-hash>.torrent`` into out_dir and exits.
|
|
106
|
+
magnet = validate_magnet(magnet)
|
|
107
|
+
meta_args: list[str] = [
|
|
108
|
+
exe,
|
|
109
|
+
"--dir", out_dir,
|
|
110
|
+
"--bt-metadata-only=true",
|
|
111
|
+
"--bt-save-metadata=true",
|
|
112
|
+
"--enable-dht=true",
|
|
113
|
+
"--bt-enable-lpd=true",
|
|
114
|
+
"--follow-torrent=mem",
|
|
115
|
+
"--seed-time=0",
|
|
116
|
+
"--summary-interval=0",
|
|
117
|
+
]
|
|
118
|
+
for t in trackers or []:
|
|
119
|
+
meta_args += ["--bt-tracker", t]
|
|
120
|
+
meta_args.append("--")
|
|
121
|
+
meta_args.append(magnet)
|
|
122
|
+
return meta_args
|
|
123
|
+
args: list[str] = [
|
|
124
|
+
exe,
|
|
125
|
+
"--dir", out_dir,
|
|
126
|
+
"--enable-dht=true",
|
|
127
|
+
"--bt-enable-lpd=true",
|
|
128
|
+
"--follow-torrent=mem",
|
|
129
|
+
"--check-integrity=true", # aria2c's own piece-hash check (belt);
|
|
130
|
+
# llmt still runs a full sha256 pass (braces)
|
|
131
|
+
"--max-connection-per-server=8",
|
|
132
|
+
"--min-split-size=1M",
|
|
133
|
+
"--summary-interval=10",
|
|
134
|
+
]
|
|
135
|
+
for t in trackers or []:
|
|
136
|
+
args += ["--bt-tracker", t]
|
|
137
|
+
|
|
138
|
+
if seed:
|
|
139
|
+
# aria2c seeds indefinitely after completion when neither --seed-time
|
|
140
|
+
# nor --seed-ratio caps it; keep verified seeding on so we only ever
|
|
141
|
+
# share correct pieces.
|
|
142
|
+
args += ["--bt-seed-unverified=false"]
|
|
143
|
+
else:
|
|
144
|
+
# Do not linger seeding once the payload is complete.
|
|
145
|
+
args += ["--seed-time=0"]
|
|
146
|
+
|
|
147
|
+
# Validate the API-supplied magnet + webseeds BEFORE they reach aria2c.
|
|
148
|
+
# Both the pull and seed paths funnel through here, so this is the single
|
|
149
|
+
# choke point that closes the aria2c argument-injection hole (C1/H1):
|
|
150
|
+
# * validate_magnet rejects any non-``magnet:?`` / option-looking value;
|
|
151
|
+
# * validate_webseed rejects any webseed that could act as an option.
|
|
152
|
+
magnet = validate_magnet(magnet)
|
|
153
|
+
safe_webseeds = [validate_webseed(w) for w in (webseeds or [])]
|
|
154
|
+
|
|
155
|
+
# Positional URIs: a literal ``--`` end-of-options separator FIRST, so that
|
|
156
|
+
# nothing after it can be parsed as an option even if validation is ever
|
|
157
|
+
# loosened (defense in depth), then the magnet, then the webseeds
|
|
158
|
+
# (attached as web-seeds).
|
|
159
|
+
args.append("--")
|
|
160
|
+
args.append(magnet)
|
|
161
|
+
for w in safe_webseeds:
|
|
162
|
+
args.append(w)
|
|
163
|
+
return args
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def run_aria2c(args: list[str]) -> int:
|
|
167
|
+
"""Run aria2c, streaming its output to the terminal; return its exit code."""
|
|
168
|
+
return subprocess.run(args).returncode
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# --------------------------------------------------------------------------- #
|
|
172
|
+
# Magnet -> .torrent resolution (Fix 14A-12)
|
|
173
|
+
#
|
|
174
|
+
# The docker harness (docs/HARNESS-magnet-metadata.md) proved that qBittorrent
|
|
175
|
+
# 4.6/5.x and Transmission do NOT fetch magnet metadata while a torrent is
|
|
176
|
+
# added paused/stopped -- so the paused-add handoff would hang at the metadata
|
|
177
|
+
# gate forever. We therefore resolve the magnet to a ``.torrent`` up front with
|
|
178
|
+
# aria2c (metadata-only, no payload) and hand the client the ``.torrent``; its
|
|
179
|
+
# piece layout is then known at add time. Resolution goes through the SAME
|
|
180
|
+
# hardened ``build_aria2c_args`` choke point (validate_magnet + literal ``--``),
|
|
181
|
+
# is bounded by a timeout, and fails closed -- the magnet is never handed to a
|
|
182
|
+
# client if resolution does not produce a matching ``.torrent``.
|
|
183
|
+
# --------------------------------------------------------------------------- #
|
|
184
|
+
|
|
185
|
+
MAGNET_RESOLVE_TIMEOUT = 180 # seconds; bounded, honest ClientError on expiry
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _bdecode(data: bytes):
|
|
189
|
+
def parse(i):
|
|
190
|
+
c = data[i:i + 1]
|
|
191
|
+
if c == b"i":
|
|
192
|
+
j = data.index(b"e", i)
|
|
193
|
+
return int(data[i + 1:j]), j + 1
|
|
194
|
+
if c == b"l":
|
|
195
|
+
i += 1
|
|
196
|
+
out = []
|
|
197
|
+
while data[i:i + 1] != b"e":
|
|
198
|
+
v, i = parse(i)
|
|
199
|
+
out.append(v)
|
|
200
|
+
return out, i + 1
|
|
201
|
+
if c == b"d":
|
|
202
|
+
i += 1
|
|
203
|
+
out = {}
|
|
204
|
+
while data[i:i + 1] != b"e":
|
|
205
|
+
k, i = parse(i)
|
|
206
|
+
v, i = parse(i)
|
|
207
|
+
out[k] = v
|
|
208
|
+
return out, i + 1
|
|
209
|
+
j = data.index(b":", i)
|
|
210
|
+
n = int(data[i:j])
|
|
211
|
+
return data[j + 1:j + 1 + n], j + 1 + n
|
|
212
|
+
return parse(0)[0]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _bencode(obj) -> bytes:
|
|
216
|
+
if isinstance(obj, bool):
|
|
217
|
+
raise TypeError("bool is not bencodable")
|
|
218
|
+
if isinstance(obj, int):
|
|
219
|
+
return b"i" + str(obj).encode() + b"e"
|
|
220
|
+
if isinstance(obj, bytes):
|
|
221
|
+
return str(len(obj)).encode() + b":" + obj
|
|
222
|
+
if isinstance(obj, str):
|
|
223
|
+
b = obj.encode()
|
|
224
|
+
return str(len(b)).encode() + b":" + b
|
|
225
|
+
if isinstance(obj, list):
|
|
226
|
+
return b"l" + b"".join(_bencode(x) for x in obj) + b"e"
|
|
227
|
+
if isinstance(obj, dict):
|
|
228
|
+
items = sorted(obj.items(),
|
|
229
|
+
key=lambda kv: kv[0] if isinstance(kv[0], bytes)
|
|
230
|
+
else str(kv[0]).encode())
|
|
231
|
+
return b"d" + b"".join(_bencode(k) + _bencode(v)
|
|
232
|
+
for k, v in items) + b"e"
|
|
233
|
+
raise TypeError("unbencodable: %r" % (type(obj),))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _infohash_v1(torrent_bytes: bytes) -> str:
|
|
237
|
+
"""SHA-1 of the bencoded ``info`` dict (BitTorrent v1 info-hash, hex)."""
|
|
238
|
+
meta = _bdecode(torrent_bytes)
|
|
239
|
+
if not isinstance(meta, dict) or b"info" not in meta:
|
|
240
|
+
raise ClientError(
|
|
241
|
+
"the .torrent aria2c produced has no info dict -- refusing to hand "
|
|
242
|
+
"it to the client; NOT seeding.")
|
|
243
|
+
return hashlib.sha1(_bencode(meta[b"info"])).hexdigest()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _magnet_btih(magnet: str) -> Optional[str]:
|
|
247
|
+
"""Return the v1 info-hash (40-hex, lowercase) named by the magnet's
|
|
248
|
+
``xt=urn:btih:`` (hex or base32), or None (e.g. a v2-only ``btmh`` magnet)."""
|
|
249
|
+
try:
|
|
250
|
+
pairs = parse_qsl(urlsplit(magnet).query, keep_blank_values=True)
|
|
251
|
+
except ValueError:
|
|
252
|
+
return None
|
|
253
|
+
for k, v in pairs:
|
|
254
|
+
if k == "xt" and v.lower().startswith("urn:btih:"):
|
|
255
|
+
h = v.split("urn:btih:", 1)[1]
|
|
256
|
+
if len(h) == 40 and all(c in "0123456789abcdefABCDEF" for c in h):
|
|
257
|
+
return h.lower()
|
|
258
|
+
if len(h) == 32:
|
|
259
|
+
try:
|
|
260
|
+
return base64.b32decode(h.upper()).hex()
|
|
261
|
+
except Exception:
|
|
262
|
+
return None
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def run_aria2c_metadata(args: list[str], timeout: int) -> int:
|
|
267
|
+
"""Run the metadata-only aria2c argv, bounded by ``timeout``. Returns the
|
|
268
|
+
exit code; raises ClientError (fail closed) if the bound is exceeded."""
|
|
269
|
+
try:
|
|
270
|
+
proc = subprocess.run(args, stdout=subprocess.PIPE,
|
|
271
|
+
stderr=subprocess.STDOUT, timeout=timeout)
|
|
272
|
+
return proc.returncode
|
|
273
|
+
except subprocess.TimeoutExpired:
|
|
274
|
+
raise ClientError(
|
|
275
|
+
f"timed out resolving the magnet to a .torrent via aria2c after "
|
|
276
|
+
f"~{timeout}s; the magnet was NOT handed to the client and it is "
|
|
277
|
+
"NOT seeding.")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def resolve_magnet_to_torrent(magnet: str, info_hash: Optional[str] = None, *,
|
|
281
|
+
timeout: int = MAGNET_RESOLVE_TIMEOUT,
|
|
282
|
+
exe: Optional[str] = None) -> bytes:
|
|
283
|
+
"""Resolve ``magnet`` to ``.torrent`` bytes via aria2c metadata-only.
|
|
284
|
+
|
|
285
|
+
Fails closed: any resolution failure (aria2c missing, non-zero exit, no
|
|
286
|
+
metadata produced, timeout, or an info-hash that does not match the
|
|
287
|
+
requested torrent) raises ClientError so the caller NEVER hands a client a
|
|
288
|
+
magnet the current clients cannot resolve while paused.
|
|
289
|
+
"""
|
|
290
|
+
magnet = validate_magnet(magnet)
|
|
291
|
+
exe = exe or ensure_aria2c()
|
|
292
|
+
workdir = tempfile.mkdtemp(prefix="llmt-magnetmeta-")
|
|
293
|
+
try:
|
|
294
|
+
args = build_aria2c_args(magnet, workdir, metadata_only=True, exe=exe)
|
|
295
|
+
rc = run_aria2c_metadata(args, timeout)
|
|
296
|
+
produced = sorted(glob.glob(os.path.join(workdir, "*.torrent")))
|
|
297
|
+
if rc != 0 or not produced:
|
|
298
|
+
raise ClientError(
|
|
299
|
+
f"aria2c could not resolve the magnet to a .torrent (exit "
|
|
300
|
+
f"{rc}); the magnet was NOT handed to the client and it is NOT "
|
|
301
|
+
"seeding.")
|
|
302
|
+
with open(produced[0], "rb") as fh:
|
|
303
|
+
torrent_bytes = fh.read()
|
|
304
|
+
# Anti-substitution: the resolved .torrent MUST be the requested
|
|
305
|
+
# torrent. aria2c already validates ut_metadata against the magnet's
|
|
306
|
+
# info-hash, but we re-check here as defense in depth before any handoff.
|
|
307
|
+
expected = _magnet_btih(magnet)
|
|
308
|
+
if expected is None and info_hash:
|
|
309
|
+
ih = str(info_hash).lower()
|
|
310
|
+
expected = ih if (len(ih) == 40
|
|
311
|
+
and all(c in "0123456789abcdef" for c in ih)) else None
|
|
312
|
+
if expected is not None:
|
|
313
|
+
got = _infohash_v1(torrent_bytes)
|
|
314
|
+
if got != expected:
|
|
315
|
+
raise ClientError(
|
|
316
|
+
"the .torrent aria2c resolved does not match the requested "
|
|
317
|
+
f"info-hash (got {got}, expected {expected}) -- refusing "
|
|
318
|
+
"to hand it to the client; NOT seeding.")
|
|
319
|
+
return torrent_bytes
|
|
320
|
+
finally:
|
|
321
|
+
shutil.rmtree(workdir, ignore_errors=True)
|
llmt/errors.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Typed errors for the llmt CLI.
|
|
2
|
+
|
|
3
|
+
Every user-facing failure raises one of these so ``cli.main`` can print a
|
|
4
|
+
clean, single-line message and pick the right non-zero exit code — the CLI
|
|
5
|
+
never shows a raw traceback for an expected condition (API 4xx/5xx/timeout,
|
|
6
|
+
a license gate, or a hash mismatch).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LlmtError(Exception):
|
|
13
|
+
"""Base for all expected CLI errors. exit_code drives sys.exit()."""
|
|
14
|
+
|
|
15
|
+
exit_code = 1
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ApiError(LlmtError):
|
|
19
|
+
"""The API returned an error envelope or an unexpected HTTP status."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, *, code: str | None = None,
|
|
22
|
+
status: int | None = None, details: dict | None = None):
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.code = code
|
|
25
|
+
self.status = status
|
|
26
|
+
self.details = details or {}
|
|
27
|
+
exit_code = 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NotFoundError(ApiError):
|
|
31
|
+
"""404 — unknown model / manifest / quant."""
|
|
32
|
+
|
|
33
|
+
exit_code = 4
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RateLimitedError(ApiError):
|
|
37
|
+
"""429 — includes Retry-After seconds when the server supplied it."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, message: str, *, retry_after: int | None = None, **kw):
|
|
40
|
+
super().__init__(message, **kw)
|
|
41
|
+
self.retry_after = retry_after
|
|
42
|
+
exit_code = 5
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TimeoutErrorLlmt(ApiError):
|
|
46
|
+
"""The API did not respond within the timeout."""
|
|
47
|
+
|
|
48
|
+
exit_code = 6
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LicenseGatedError(LlmtError):
|
|
52
|
+
"""D7 — the model/quant is license-gated; no artifacts are served.
|
|
53
|
+
|
|
54
|
+
Carries ONLY the human model_page URL. It never carries (or lets the
|
|
55
|
+
caller fabricate) a magnet, info hash, file path or sha256.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, slug: str, model_page: str | None):
|
|
59
|
+
self.slug = slug
|
|
60
|
+
self.model_page = model_page
|
|
61
|
+
msg = (f"'{slug}' is license-gated. The catalog API does not serve "
|
|
62
|
+
f"download artifacts for gated models.")
|
|
63
|
+
super().__init__(msg)
|
|
64
|
+
exit_code = 7
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class NotAvailableError(LlmtError):
|
|
68
|
+
"""The quant has no gate-passed torrent to download."""
|
|
69
|
+
|
|
70
|
+
exit_code = 8
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class VerificationError(LlmtError):
|
|
74
|
+
"""One or more files failed SHA-256 verification. THE trust signal."""
|
|
75
|
+
|
|
76
|
+
exit_code = 3
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class MissingDependencyError(LlmtError):
|
|
80
|
+
"""A required external tool (aria2c) is not installed."""
|
|
81
|
+
|
|
82
|
+
exit_code = 9
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class LayoutError(LlmtError):
|
|
86
|
+
"""Base for seeding-layout mapper failures (WP-14.2b)."""
|
|
87
|
+
|
|
88
|
+
exit_code = 10
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class LayoutSafetyError(LayoutError):
|
|
92
|
+
"""An in-torrent path failed the untrusted-input safety checks.
|
|
93
|
+
|
|
94
|
+
Raised for path traversal (``..``), absolute/drive-letter/UNC tricks,
|
|
95
|
+
embedded nulls or control chars, reserved device names, or a planned
|
|
96
|
+
link whose destination would escape the layouts base dir.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
exit_code = 11
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class LayoutSourceError(LayoutError):
|
|
103
|
+
"""The user's local artifact is missing or not a regular file."""
|
|
104
|
+
|
|
105
|
+
exit_code = 12
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class LayoutApplyError(LayoutError):
|
|
109
|
+
"""A mkdir/hardlink/symlink action failed, or an existing link points at
|
|
110
|
+
the WRONG target (we never silently replace it)."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, message: str, *, hint: str | None = None):
|
|
113
|
+
self.hint = hint
|
|
114
|
+
super().__init__(message if not hint else f"{message} Hint: {hint}")
|
|
115
|
+
|
|
116
|
+
exit_code = 13
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ManifestVerificationError(LlmtError):
|
|
120
|
+
"""The Ed25519-signed manifest could not be obtained or did not verify.
|
|
121
|
+
|
|
122
|
+
Fail-closed authenticity signal for ``pull``/``verify``/``seed`` (Fix
|
|
123
|
+
M2, Option A): if the manifest is unreachable (including offline),
|
|
124
|
+
malformed, signed with an unknown or wrong key, carries a bad
|
|
125
|
+
signature, names a different torrent, or disagrees with the catalog's
|
|
126
|
+
file list, NOTHING is reported as "verified" and no seed starts. The
|
|
127
|
+
message always states the specific reason; there is no silent
|
|
128
|
+
downgrade to unauthenticated (TLS-only) hash checking.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
code = "manifest_unverified"
|
|
132
|
+
exit_code = 15
|