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/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