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/layout.py
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
"""Seeding-layout mapper / link-farm planner (WP-14.2b).
|
|
2
|
+
|
|
3
|
+
SHA-256 identity proves a file *is* the right bytes, but a BitTorrent client
|
|
4
|
+
will not seed those bytes unless they are presented at the exact path the
|
|
5
|
+
torrent expects, under the torrent's root folder. The user's model already
|
|
6
|
+
lives somewhere on disk (``/models/qwen3-30b/model.gguf``); the torrent wants
|
|
7
|
+
``qwen3-30b-a3b-q4-k-m/Qwen3-30B-A3B-Q4_K_M.gguf`` under some save path. This
|
|
8
|
+
module *plans* how to present the user's existing file to the client WITHOUT
|
|
9
|
+
moving or copying it — by choosing a save path, or by building a small tree of
|
|
10
|
+
hard/symbolic links (a "link farm") that mirrors the expected layout and points
|
|
11
|
+
back at the real file. Your model folder stays your model folder: model
|
|
12
|
+
weights are never re-downloaded. (One honest caveat, M5: when a multi-file
|
|
13
|
+
torrent is only partially present, missing license/README-class companion
|
|
14
|
+
files up to 10 MiB total per container may be fetched by the client, with an
|
|
15
|
+
explicit notice; larger missing files are marked do-not-download instead.)
|
|
16
|
+
|
|
17
|
+
The engine is split into pure policy (sanitising untrusted server paths, the
|
|
18
|
+
link-type decision matrix, direct-use path matching, Windows drive logic) and
|
|
19
|
+
the thin syscall layer (``apply_plan``). The pure parts are fully unit-testable
|
|
20
|
+
on any OS with no filesystem and no network.
|
|
21
|
+
|
|
22
|
+
Contract note for WP-14.4-S: the ``LayoutPlan`` / ``PlanAction`` dataclasses and
|
|
23
|
+
``plan_layout`` / ``apply_plan`` signatures below are the frozen public API the
|
|
24
|
+
scan flow builds on. Consumers must branch on ``plan.mode`` to find the
|
|
25
|
+
client-visible path: for ``link-farm`` it is the last action's ``target`` under
|
|
26
|
+
``save_path``; for ``direct-use`` it is ``source_file`` itself, with
|
|
27
|
+
``save_path`` being its containing directory.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import ntpath
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from typing import Any, Optional
|
|
37
|
+
|
|
38
|
+
from .errors import (
|
|
39
|
+
LayoutApplyError,
|
|
40
|
+
LayoutError,
|
|
41
|
+
LayoutSafetyError,
|
|
42
|
+
LayoutSourceError,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Constants
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
#: Default root for generated link farms.
|
|
50
|
+
DEFAULT_BASE_DIR = os.path.join("~", ".llmt", "seeding-layouts")
|
|
51
|
+
|
|
52
|
+
MAX_SEGMENT = 255 # per-path-component length ceiling (POSIX/NTFS practical max)
|
|
53
|
+
|
|
54
|
+
# Windows reserved device names (case-insensitive, with or without extension).
|
|
55
|
+
_RESERVED = {"CON", "PRN", "AUX", "NUL"} | {f"COM{i}" for i in range(1, 10)} | {
|
|
56
|
+
f"LPT{i}" for i in range(1, 10)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
LINK_NONE = "none"
|
|
60
|
+
LINK_HARDLINK = "hardlink"
|
|
61
|
+
LINK_SYMLINK = "symlink"
|
|
62
|
+
|
|
63
|
+
MODE_DIRECT = "direct-use"
|
|
64
|
+
MODE_LINKFARM = "link-farm"
|
|
65
|
+
|
|
66
|
+
# M5 size-aware sibling policy (Pat's decision, size-aware Option C,
|
|
67
|
+
# 2026-07-13): when a multi-file container is only PARTIALLY present locally,
|
|
68
|
+
# a missing sibling file may be auto-fetched by the torrent client (with an
|
|
69
|
+
# explicit notice) ONLY if it is metadata-class tiny: its own size is
|
|
70
|
+
# <= M5_AUTO_FETCH_MAX_BYTES AND the cumulative size of every auto-fetched
|
|
71
|
+
# file for that container stays <= M5_AUTO_FETCH_MAX_BYTES. Anything larger
|
|
72
|
+
# (model weights/shards) is NEVER downloaded on the user's behalf: it must be
|
|
73
|
+
# marked do-not-download in the client BEFORE any resume, with a warning that
|
|
74
|
+
# the torrent will seed only what the user has and will show as incomplete.
|
|
75
|
+
M5_AUTO_FETCH_MAX_BYTES = 10 * 1024 * 1024
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def classify_missing_files(
|
|
79
|
+
missing: "list[tuple[str, Optional[int]]]",
|
|
80
|
+
*,
|
|
81
|
+
max_bytes: int = M5_AUTO_FETCH_MAX_BYTES,
|
|
82
|
+
) -> "tuple[list[tuple[str, Optional[int]]], list[tuple[str, Optional[int]]]]":
|
|
83
|
+
"""Pure M5 classifier: split a container's missing files into
|
|
84
|
+
``(fetch, skip)``.
|
|
85
|
+
|
|
86
|
+
``missing`` is ``[(in_torrent_path, size_bytes_or_None), ...]``.
|
|
87
|
+
|
|
88
|
+
A file goes into ``fetch`` (auto-fetch with a notice) iff its size is
|
|
89
|
+
KNOWN, ``<= max_bytes``, and the running total of everything already in
|
|
90
|
+
``fetch`` stays ``<= max_bytes``. Budget is assigned smallest-first so
|
|
91
|
+
genuinely tiny metadata files (license/readme) win over mid-size ones;
|
|
92
|
+
ties break on path for determinism. A file with UNKNOWN size can never
|
|
93
|
+
be proven tiny, so it fails closed into ``skip`` (do-not-download).
|
|
94
|
+
Everything else lands in ``skip``.
|
|
95
|
+
"""
|
|
96
|
+
eligible = sorted(
|
|
97
|
+
(
|
|
98
|
+
(path, size) for path, size in missing
|
|
99
|
+
if isinstance(size, int) and not isinstance(size, bool)
|
|
100
|
+
and 0 <= size <= max_bytes
|
|
101
|
+
),
|
|
102
|
+
key=lambda ps: (ps[1], ps[0]),
|
|
103
|
+
)
|
|
104
|
+
fetch: list[tuple[str, Optional[int]]] = []
|
|
105
|
+
fetched_paths: set = set()
|
|
106
|
+
total = 0
|
|
107
|
+
for path, size in eligible:
|
|
108
|
+
if total + size <= max_bytes:
|
|
109
|
+
fetch.append((path, size))
|
|
110
|
+
fetched_paths.add(path)
|
|
111
|
+
total += size
|
|
112
|
+
skip = [(path, size) for path, size in missing
|
|
113
|
+
if path not in fetched_paths]
|
|
114
|
+
return fetch, skip
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
#: Documented per-client link-following behaviour. ``True`` = verified in the
|
|
118
|
+
#: client's docs/behaviour; ``"conservative"`` = unverified, warn the user.
|
|
119
|
+
CLIENT_LINK_SUPPORT: dict[str, dict[str, Any]] = {
|
|
120
|
+
"qbittorrent": {"hardlink": True, "symlink": True},
|
|
121
|
+
"transmission": {"hardlink": True, "symlink": True},
|
|
122
|
+
"deluge": {"hardlink": True, "symlink": True},
|
|
123
|
+
"rtorrent": {"hardlink": True, "symlink": True},
|
|
124
|
+
"generic": {"hardlink": True, "symlink": "conservative"},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# qBittorrent "add + recheck, seed from existing bytes, never redownload" recipe.
|
|
128
|
+
# WP-14.4-S implements the WebUI calls; this is the contract it targets.
|
|
129
|
+
QBITTORRENT_ADD_RECIPE: dict[str, Any] = {
|
|
130
|
+
"endpoint": "/api/v2/torrents/add",
|
|
131
|
+
"savepath": "<LayoutPlan.save_path>",
|
|
132
|
+
"autoTMM": False, # do NOT let the client relocate/manage the path
|
|
133
|
+
"paused": True, # add paused so we control the recheck order
|
|
134
|
+
"skip_checking": False, # MUST hash-check so it discovers the linked bytes
|
|
135
|
+
"then": [
|
|
136
|
+
"POST /api/v2/torrents/recheck (client hashes the linked files -> 100%)",
|
|
137
|
+
"POST /api/v2/torrents/resume (start seeding once verified 100%)",
|
|
138
|
+
],
|
|
139
|
+
"notes": (
|
|
140
|
+
"With autoTMM off and savepath == plan.save_path, the client resolves "
|
|
141
|
+
"each in-torrent path under savepath, follows the link farm to the real "
|
|
142
|
+
"file, verifies SHA/piece hashes, and seeds. Model weights are never "
|
|
143
|
+
"re-downloaded; at most, small missing companion files (M5: license/"
|
|
144
|
+
"README-class, <=10 MiB total per container, announced with a notice) "
|
|
145
|
+
"are fetched -- any larger missing file is marked do-not-download "
|
|
146
|
+
"(priority 0 / files-unwanted) BEFORE the torrent is resumed."
|
|
147
|
+
),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# Dataclasses (FROZEN public contract for WP-14.4-S)
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class PlanAction:
|
|
157
|
+
"""One step in a plan.
|
|
158
|
+
|
|
159
|
+
kind: "mkdir" | "hardlink" | "symlink" | "none"
|
|
160
|
+
target: absolute path the action creates (dir, or link path)
|
|
161
|
+
source: absolute real path of the user's file (link kinds); None otherwise
|
|
162
|
+
rationale: why this action exists
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
kind: str
|
|
166
|
+
target: str
|
|
167
|
+
source: Optional[str]
|
|
168
|
+
rationale: str
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class LayoutPlan:
|
|
173
|
+
"""A pure data description of how to present a file for seeding.
|
|
174
|
+
|
|
175
|
+
mode: "direct-use" | "link-farm"
|
|
176
|
+
save_path: absolute path to hand the client as its save/download path
|
|
177
|
+
link_type: "none" | "hardlink" | "symlink"
|
|
178
|
+
actions: ordered PlanAction list (mkdir before link)
|
|
179
|
+
warnings: human-readable caveats (platform / client / collision)
|
|
180
|
+
torrent_root: sanitised torrent root folder ("" for single-file torrents)
|
|
181
|
+
in_torrent_path: sanitised POSIX relative path inside the torrent
|
|
182
|
+
source_file: resolved absolute real path of the user's artifact
|
|
183
|
+
requires_recheck: client must force-recheck to reach 100% (always True)
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
mode: str
|
|
187
|
+
save_path: str
|
|
188
|
+
link_type: str
|
|
189
|
+
actions: list[PlanAction]
|
|
190
|
+
warnings: list[str] = field(default_factory=list)
|
|
191
|
+
torrent_root: str = ""
|
|
192
|
+
in_torrent_path: str = ""
|
|
193
|
+
source_file: str = ""
|
|
194
|
+
requires_recheck: bool = True
|
|
195
|
+
|
|
196
|
+
def describe(self) -> str:
|
|
197
|
+
"""Human-readable multi-line summary (used by dry-run / CLI later)."""
|
|
198
|
+
lines = [
|
|
199
|
+
f"mode={self.mode} link_type={self.link_type}",
|
|
200
|
+
f"save_path: {self.save_path}",
|
|
201
|
+
f"in_torrent_path: {self.in_torrent_path or '(single file)'}",
|
|
202
|
+
]
|
|
203
|
+
for a in self.actions:
|
|
204
|
+
arrow = f" -> {a.source}" if a.source else ""
|
|
205
|
+
lines.append(f" [{a.kind}] {a.target}{arrow} # {a.rationale}")
|
|
206
|
+
for w in self.warnings:
|
|
207
|
+
lines.append(f" ! {w}")
|
|
208
|
+
if self.requires_recheck:
|
|
209
|
+
lines.append(" (client must force-recheck to reach 100%)")
|
|
210
|
+
return "\n".join(lines)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
# Pure helpers: OS + path sanitising
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def normalize_os(os_name: Optional[str]) -> str:
|
|
218
|
+
"""Map to canonical 'posix' | 'windows'. None -> the current interpreter."""
|
|
219
|
+
if os_name is None:
|
|
220
|
+
return "windows" if os.name == "nt" else "posix"
|
|
221
|
+
o = os_name.strip().lower()
|
|
222
|
+
if o in ("windows", "win", "win32", "nt"):
|
|
223
|
+
return "windows"
|
|
224
|
+
if o in ("posix", "linux", "mac", "macos", "darwin", "unix"):
|
|
225
|
+
return "posix"
|
|
226
|
+
raise LayoutError(f"unknown os hint {os_name!r}")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _validate_segment(seg: str) -> None:
|
|
230
|
+
if seg == "..":
|
|
231
|
+
raise LayoutSafetyError("path traversal segment '..' rejected")
|
|
232
|
+
if "\x00" in seg:
|
|
233
|
+
raise LayoutSafetyError("embedded null byte in path segment")
|
|
234
|
+
if any(ord(c) < 32 for c in seg):
|
|
235
|
+
raise LayoutSafetyError(f"control character in path segment {seg!r}")
|
|
236
|
+
if ":" in seg:
|
|
237
|
+
# kills drive letters (C:) and NTFS alternate data streams (file:stream)
|
|
238
|
+
raise LayoutSafetyError(f"illegal ':' in path segment {seg!r}")
|
|
239
|
+
if len(seg) > MAX_SEGMENT:
|
|
240
|
+
raise LayoutSafetyError(
|
|
241
|
+
f"path segment too long ({len(seg)} > {MAX_SEGMENT}): {seg[:32]!r}..."
|
|
242
|
+
)
|
|
243
|
+
# Windows silently strips trailing dots/spaces when resolving names, so
|
|
244
|
+
# "CON " or "NUL." still hits the device. Strip them BEFORE the reserved
|
|
245
|
+
# compare, and reject a segment that strips to nothing.
|
|
246
|
+
stripped = seg.rstrip(" .")
|
|
247
|
+
if not stripped:
|
|
248
|
+
raise LayoutSafetyError(
|
|
249
|
+
f"path segment {seg!r} is empty after stripping trailing dots/spaces"
|
|
250
|
+
)
|
|
251
|
+
stem = stripped.split(".", 1)[0].upper()
|
|
252
|
+
if stem in _RESERVED:
|
|
253
|
+
raise LayoutSafetyError(f"reserved device name in path: {seg!r}")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def sanitize_relpath(raw: Optional[str]) -> list[str]:
|
|
257
|
+
"""Turn an untrusted server-supplied in-torrent path into safe segments.
|
|
258
|
+
|
|
259
|
+
Splits on BOTH separators (a hostile server could send backslashes), drops
|
|
260
|
+
empty and ``.`` segments, and rejects anything dangerous. Returns the clean
|
|
261
|
+
ordered segment list (never empty). Absolute inputs are neutralised into a
|
|
262
|
+
relative path (their leading separator is simply dropped).
|
|
263
|
+
"""
|
|
264
|
+
if raw is None:
|
|
265
|
+
raise LayoutSafetyError("in-torrent path is empty")
|
|
266
|
+
if "\x00" in raw:
|
|
267
|
+
raise LayoutSafetyError("embedded null byte in path")
|
|
268
|
+
parts = re.split(r"[\\/]+", raw.strip())
|
|
269
|
+
segments: list[str] = []
|
|
270
|
+
for seg in parts:
|
|
271
|
+
if seg in ("", "."):
|
|
272
|
+
continue
|
|
273
|
+
_validate_segment(seg)
|
|
274
|
+
segments.append(seg)
|
|
275
|
+
if not segments:
|
|
276
|
+
raise LayoutSafetyError(f"path {raw!r} has no usable segments")
|
|
277
|
+
return segments
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def safe_join(base_abs: str, segments: list[str]) -> str:
|
|
281
|
+
"""Join sanitised segments under ``base_abs`` and prove no escape.
|
|
282
|
+
|
|
283
|
+
Belt-and-suspenders on top of :func:`sanitize_relpath`: normalises and
|
|
284
|
+
verifies the result still lives under ``base_abs`` via ``commonpath``.
|
|
285
|
+
"""
|
|
286
|
+
base_norm = os.path.normpath(base_abs)
|
|
287
|
+
joined = os.path.normpath(os.path.join(base_norm, *segments))
|
|
288
|
+
try:
|
|
289
|
+
common = os.path.commonpath([base_norm, joined])
|
|
290
|
+
except ValueError: # different drives on Windows
|
|
291
|
+
raise LayoutSafetyError(f"link path {joined!r} escapes base {base_norm!r}")
|
|
292
|
+
if os.path.normcase(common) != os.path.normcase(base_norm):
|
|
293
|
+
raise LayoutSafetyError(f"link path {joined!r} escapes base {base_norm!r}")
|
|
294
|
+
return joined
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def directory_for_direct_use(
|
|
298
|
+
local_abspath: str,
|
|
299
|
+
in_torrent_segments: list[str],
|
|
300
|
+
*,
|
|
301
|
+
sep: str = os.sep,
|
|
302
|
+
case_sensitive: bool = True,
|
|
303
|
+
) -> Optional[str]:
|
|
304
|
+
"""If the file already sits at ``D/<in-torrent path>``, return ``D``.
|
|
305
|
+
|
|
306
|
+
Pure: matches the trailing segments of ``local_abspath`` against the
|
|
307
|
+
expected in-torrent segments. Returns the directory ``D`` (the save path
|
|
308
|
+
for the direct-use case) or None when the layout does not already match.
|
|
309
|
+
"""
|
|
310
|
+
parts = [p for p in local_abspath.split(sep) if p != ""]
|
|
311
|
+
n = len(in_torrent_segments)
|
|
312
|
+
if n == 0 or len(parts) < n:
|
|
313
|
+
return None
|
|
314
|
+
tail = parts[-n:]
|
|
315
|
+
if case_sensitive:
|
|
316
|
+
if tail != in_torrent_segments:
|
|
317
|
+
return None
|
|
318
|
+
else:
|
|
319
|
+
if [t.lower() for t in tail] != [s.lower() for s in in_torrent_segments]:
|
|
320
|
+
return None
|
|
321
|
+
lead = parts[:-n]
|
|
322
|
+
if local_abspath.startswith(sep):
|
|
323
|
+
return sep + sep.join(lead) if lead else sep
|
|
324
|
+
return sep.join(lead) if lead else "."
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
# Pure helpers: link-type decision matrix
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
def decide_link_type(
|
|
332
|
+
os_name: str,
|
|
333
|
+
same_volume: bool,
|
|
334
|
+
client: str = "qbittorrent",
|
|
335
|
+
) -> tuple[str, list[str]]:
|
|
336
|
+
"""The link-type decision matrix (pure, exhaustively unit-tested).
|
|
337
|
+
|
|
338
|
+
Rule of thumb: prefer a hardlink whenever the file and the farm live on the
|
|
339
|
+
same volume — hardlinks are invisible to the client, need no privileges, and
|
|
340
|
+
every client follows them. A symlink is the labelled cross-volume fallback.
|
|
341
|
+
|
|
342
|
+
POSIX same volume -> hardlink
|
|
343
|
+
POSIX cross volume -> symlink (hardlink impossible across filesystems)
|
|
344
|
+
Win same volume -> hardlink (same drive, no admin needed)
|
|
345
|
+
Win cross volume -> symlink (junctions redirect DIRS only, not files;
|
|
346
|
+
symlink needs Admin / Developer Mode)
|
|
347
|
+
"""
|
|
348
|
+
warnings: list[str] = []
|
|
349
|
+
client = (client or "generic").lower()
|
|
350
|
+
caps = CLIENT_LINK_SUPPORT.get(client, CLIENT_LINK_SUPPORT["generic"])
|
|
351
|
+
|
|
352
|
+
if same_volume:
|
|
353
|
+
return LINK_HARDLINK, warnings
|
|
354
|
+
|
|
355
|
+
if os_name == "windows":
|
|
356
|
+
warnings.append(
|
|
357
|
+
"Cross-volume on Windows: hardlinks cannot span volumes, and NTFS "
|
|
358
|
+
"junctions redirect DIRECTORIES only (not individual files), so the "
|
|
359
|
+
"only option for a file is a symbolic link, which requires "
|
|
360
|
+
"Administrator privileges or Windows Developer Mode."
|
|
361
|
+
)
|
|
362
|
+
else:
|
|
363
|
+
warnings.append(
|
|
364
|
+
"Cross-filesystem: hardlinks cannot span filesystems; using a "
|
|
365
|
+
"symbolic link instead."
|
|
366
|
+
)
|
|
367
|
+
if caps.get("symlink") is not True:
|
|
368
|
+
warnings.append(
|
|
369
|
+
f"Client '{client}' symlink-following is unverified; qBittorrent "
|
|
370
|
+
f"follows symlinks — confirm your client does before relying on it."
|
|
371
|
+
)
|
|
372
|
+
return LINK_SYMLINK, warnings
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _same_drive_windows(a: str, b: str) -> bool:
|
|
376
|
+
"""Heuristic: same Windows volume == same drive letter / UNC share.
|
|
377
|
+
|
|
378
|
+
We compare ``os.path.splitdrive`` prefixes case-insensitively. This is a
|
|
379
|
+
heuristic — mounted volume GUIDs and NTFS mount points can defeat drive-
|
|
380
|
+
letter comparison — so cross-volume mis-detection degrades safely toward the
|
|
381
|
+
symlink fallback, never toward a hardlink that would fail at apply time.
|
|
382
|
+
"""
|
|
383
|
+
# ntpath (not os.path) so the Windows heuristic is correct even when the
|
|
384
|
+
# planner runs on a POSIX host planning for a Windows client.
|
|
385
|
+
da = ntpath.splitdrive(a)[0].upper()
|
|
386
|
+
db = ntpath.splitdrive(b)[0].upper()
|
|
387
|
+
return da == db
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _nearest_existing(path: str) -> str:
|
|
391
|
+
p = os.path.abspath(path)
|
|
392
|
+
while p and not os.path.exists(p):
|
|
393
|
+
parent = os.path.dirname(p)
|
|
394
|
+
if parent == p:
|
|
395
|
+
break
|
|
396
|
+
p = parent
|
|
397
|
+
return p
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def detect_same_volume(source_real: str, dest_base: str, os_name: str) -> bool:
|
|
401
|
+
"""Are the user's file and the (maybe-not-yet-existing) farm co-located?
|
|
402
|
+
|
|
403
|
+
POSIX: compare ``st_dev`` of the source and the nearest existing ancestor of
|
|
404
|
+
the farm base. Windows: compare drive letters (documented heuristic above).
|
|
405
|
+
"""
|
|
406
|
+
if os_name == "windows":
|
|
407
|
+
return _same_drive_windows(source_real, dest_base)
|
|
408
|
+
src_dev = os.stat(source_real).st_dev
|
|
409
|
+
dst_dev = os.stat(_nearest_existing(dest_base)).st_dev
|
|
410
|
+
return src_dev == dst_dev
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
# Planner
|
|
415
|
+
# ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
def _container_path(container: dict[str, Any]) -> str:
|
|
418
|
+
raw = container.get("path") or container.get("file_name")
|
|
419
|
+
if not raw:
|
|
420
|
+
raise LayoutError(
|
|
421
|
+
"container has neither 'path' nor 'file_name' to map a layout from"
|
|
422
|
+
)
|
|
423
|
+
return raw
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def plan_layout(
|
|
427
|
+
local_file: str,
|
|
428
|
+
container: dict[str, Any],
|
|
429
|
+
*,
|
|
430
|
+
base_dir: Optional[str] = None,
|
|
431
|
+
client: str = "qbittorrent",
|
|
432
|
+
os_name: Optional[str] = None,
|
|
433
|
+
same_device: Optional[bool] = None,
|
|
434
|
+
) -> LayoutPlan:
|
|
435
|
+
"""Plan how to present ``local_file`` for seeding ``container``.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
local_file: absolute path to the user's SHA-verified artifact.
|
|
439
|
+
container: a WP-14.2 container object (uses ``path`` / ``file_name``).
|
|
440
|
+
base_dir: link-farm root (default ``~/.llmt/seeding-layouts``).
|
|
441
|
+
client: client hint for the symlink-caveat table.
|
|
442
|
+
os_name: "posix" | "windows" | None (current interpreter).
|
|
443
|
+
same_device: override device-colocation detection (mainly for tests).
|
|
444
|
+
|
|
445
|
+
Returns a :class:`LayoutPlan`. Does not touch the filesystem except to stat
|
|
446
|
+
the source (existence / regular-file / device) — no dirs or links created.
|
|
447
|
+
"""
|
|
448
|
+
os_name = normalize_os(os_name)
|
|
449
|
+
|
|
450
|
+
# 1. Validate the source. We resolve symlinks so a symlinked input still
|
|
451
|
+
# yields a correct link to the underlying regular file; we require the
|
|
452
|
+
# RESOLVED path to be an existing regular file.
|
|
453
|
+
src = os.path.abspath(os.path.expanduser(local_file))
|
|
454
|
+
real = os.path.realpath(src)
|
|
455
|
+
if not os.path.lexists(src):
|
|
456
|
+
raise LayoutSourceError(f"source file does not exist: {local_file}")
|
|
457
|
+
if not os.path.exists(real):
|
|
458
|
+
raise LayoutSourceError(
|
|
459
|
+
f"source is a broken symlink (resolves to a missing target): {local_file}"
|
|
460
|
+
)
|
|
461
|
+
if not os.path.isfile(real):
|
|
462
|
+
raise LayoutSourceError(
|
|
463
|
+
f"source is not a regular file (dir/device/socket?): {local_file}"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# 2. Sanitise the untrusted in-torrent path.
|
|
467
|
+
segments = sanitize_relpath(_container_path(container))
|
|
468
|
+
torrent_root = segments[0] if len(segments) > 1 else ""
|
|
469
|
+
in_torrent_path = "/".join(segments)
|
|
470
|
+
|
|
471
|
+
# 3. Direct-use: the file already sits at D/<in-torrent path>.
|
|
472
|
+
case_sensitive = os_name != "windows"
|
|
473
|
+
d = directory_for_direct_use(
|
|
474
|
+
real, segments, sep=os.sep, case_sensitive=case_sensitive
|
|
475
|
+
)
|
|
476
|
+
if d is not None:
|
|
477
|
+
return LayoutPlan(
|
|
478
|
+
mode=MODE_DIRECT,
|
|
479
|
+
save_path=os.path.normpath(d),
|
|
480
|
+
link_type=LINK_NONE,
|
|
481
|
+
actions=[PlanAction(
|
|
482
|
+
kind="none", target=real, source=None,
|
|
483
|
+
rationale="file already at the exact expected relative path; "
|
|
484
|
+
"add the torrent with this save path, no links needed",
|
|
485
|
+
)],
|
|
486
|
+
warnings=[],
|
|
487
|
+
torrent_root=torrent_root,
|
|
488
|
+
in_torrent_path=in_torrent_path,
|
|
489
|
+
source_file=real,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
# 4. Link-farm.
|
|
493
|
+
base = base_dir if base_dir is not None else DEFAULT_BASE_DIR
|
|
494
|
+
base_abs = os.path.normpath(os.path.abspath(os.path.expanduser(base)))
|
|
495
|
+
link_path = safe_join(base_abs, segments)
|
|
496
|
+
dest_parent = os.path.dirname(link_path)
|
|
497
|
+
|
|
498
|
+
if same_device is None:
|
|
499
|
+
same_device = detect_same_volume(real, base_abs, os_name)
|
|
500
|
+
link_type, warnings = decide_link_type(os_name, same_device, client)
|
|
501
|
+
|
|
502
|
+
actions = [
|
|
503
|
+
PlanAction(
|
|
504
|
+
kind="mkdir", target=dest_parent, source=None,
|
|
505
|
+
rationale="create the link-farm directory mirroring the torrent root",
|
|
506
|
+
),
|
|
507
|
+
PlanAction(
|
|
508
|
+
kind=link_type, target=link_path, source=real,
|
|
509
|
+
rationale=(
|
|
510
|
+
f"{link_type} the expected in-torrent path to the user's real "
|
|
511
|
+
f"file (no copy, no move)"
|
|
512
|
+
),
|
|
513
|
+
),
|
|
514
|
+
]
|
|
515
|
+
|
|
516
|
+
if torrent_root == "":
|
|
517
|
+
warnings = warnings + [
|
|
518
|
+
"Single-file torrent (no root folder): the link lives directly under "
|
|
519
|
+
"the layouts base and could collide with another single-file "
|
|
520
|
+
"torrent's file of the same name — use a per-torrent base_dir if you "
|
|
521
|
+
"seed several single-file torrents."
|
|
522
|
+
]
|
|
523
|
+
|
|
524
|
+
return LayoutPlan(
|
|
525
|
+
mode=MODE_LINKFARM,
|
|
526
|
+
save_path=base_abs,
|
|
527
|
+
link_type=link_type,
|
|
528
|
+
actions=actions,
|
|
529
|
+
warnings=warnings,
|
|
530
|
+
torrent_root=torrent_root,
|
|
531
|
+
in_torrent_path=in_torrent_path,
|
|
532
|
+
source_file=real,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# ---------------------------------------------------------------------------
|
|
537
|
+
# Executor
|
|
538
|
+
# ---------------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
def _assert_farm_path_safe(base: str, path: str) -> None:
|
|
541
|
+
"""F1 defense: refuse to write through symlinked components under the farm.
|
|
542
|
+
|
|
543
|
+
``safe_join``'s containment is lexical-only; once the real filesystem is
|
|
544
|
+
involved, a pre-planted symlink at ``<base>/<root>`` (or any intermediate
|
|
545
|
+
component) would redirect mkdir/link through it to an arbitrary location
|
|
546
|
+
outside the farm. Before any write we (a) walk every path component below
|
|
547
|
+
``realpath(base)`` and reject symlinks, and (b) verify the realpath of the
|
|
548
|
+
destination still resolves inside the real base.
|
|
549
|
+
"""
|
|
550
|
+
base_norm = os.path.normpath(os.path.abspath(base))
|
|
551
|
+
base_real = os.path.realpath(base_norm)
|
|
552
|
+
path_norm = os.path.normpath(os.path.abspath(path))
|
|
553
|
+
rel = os.path.relpath(path_norm, base_norm)
|
|
554
|
+
if rel == os.curdir:
|
|
555
|
+
return
|
|
556
|
+
if rel == os.pardir or rel.startswith(os.pardir + os.sep) or os.path.isabs(rel):
|
|
557
|
+
raise LayoutSafetyError(
|
|
558
|
+
f"farm path {path_norm!r} escapes base {base_norm!r}"
|
|
559
|
+
)
|
|
560
|
+
cur = base_real
|
|
561
|
+
for seg in rel.split(os.sep):
|
|
562
|
+
if seg in ("", os.curdir):
|
|
563
|
+
continue
|
|
564
|
+
cur = os.path.join(cur, seg)
|
|
565
|
+
if os.path.islink(cur):
|
|
566
|
+
raise LayoutSafetyError(
|
|
567
|
+
f"refusing to write through symlinked farm component: {cur!r}"
|
|
568
|
+
)
|
|
569
|
+
real = os.path.realpath(path_norm)
|
|
570
|
+
try:
|
|
571
|
+
common = os.path.commonpath([base_real, real])
|
|
572
|
+
except ValueError:
|
|
573
|
+
raise LayoutSafetyError(
|
|
574
|
+
f"farm path {path_norm!r} resolves outside base {base_real!r}"
|
|
575
|
+
)
|
|
576
|
+
if os.path.normcase(common) != os.path.normcase(base_real):
|
|
577
|
+
raise LayoutSafetyError(
|
|
578
|
+
f"farm path {path_norm!r} resolves to {real!r}, outside base {base_real!r}"
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _existing_target_status(action: PlanAction) -> str:
|
|
583
|
+
"""'absent' | 'ok' (idempotent match) | 'wrong' (points elsewhere)."""
|
|
584
|
+
t = action.target
|
|
585
|
+
if not os.path.lexists(t):
|
|
586
|
+
return "absent"
|
|
587
|
+
if action.kind == LINK_HARDLINK:
|
|
588
|
+
try:
|
|
589
|
+
if os.path.exists(t) and os.path.samefile(t, action.source):
|
|
590
|
+
return "ok"
|
|
591
|
+
except OSError:
|
|
592
|
+
return "wrong"
|
|
593
|
+
return "wrong"
|
|
594
|
+
if action.kind == LINK_SYMLINK:
|
|
595
|
+
if os.path.islink(t):
|
|
596
|
+
try:
|
|
597
|
+
if os.path.realpath(t) == os.path.realpath(action.source):
|
|
598
|
+
return "ok"
|
|
599
|
+
if os.readlink(t) == action.source:
|
|
600
|
+
return "ok"
|
|
601
|
+
except OSError:
|
|
602
|
+
return "wrong"
|
|
603
|
+
return "wrong"
|
|
604
|
+
return "absent"
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def apply_plan(plan: LayoutPlan, *, dry_run: bool = False) -> list[str]:
|
|
608
|
+
"""Execute (or, with ``dry_run``, describe) a plan. Idempotent.
|
|
609
|
+
|
|
610
|
+
Re-applying a plan is a series of no-ops. An existing link that already
|
|
611
|
+
points at the right file is left alone; an existing path that points at the
|
|
612
|
+
WRONG target raises :class:`LayoutApplyError` — we NEVER silently replace it.
|
|
613
|
+
Returns a human-readable action log.
|
|
614
|
+
"""
|
|
615
|
+
log: list[str] = []
|
|
616
|
+
|
|
617
|
+
# F1: validate every write destination against planted-symlink redirection
|
|
618
|
+
# BEFORE executing anything (also in dry-run — a poisoned farm is an error).
|
|
619
|
+
if plan.mode == MODE_LINKFARM:
|
|
620
|
+
for act in plan.actions:
|
|
621
|
+
if act.kind == "mkdir":
|
|
622
|
+
_assert_farm_path_safe(plan.save_path, act.target)
|
|
623
|
+
elif act.kind in (LINK_HARDLINK, LINK_SYMLINK):
|
|
624
|
+
_assert_farm_path_safe(plan.save_path, os.path.dirname(act.target))
|
|
625
|
+
|
|
626
|
+
for act in plan.actions:
|
|
627
|
+
if act.kind == "none":
|
|
628
|
+
log.append(f"ok (direct-use): {act.target} already in place")
|
|
629
|
+
continue
|
|
630
|
+
|
|
631
|
+
if act.kind == "mkdir":
|
|
632
|
+
if dry_run:
|
|
633
|
+
log.append(f"mkdir -p {act.target}")
|
|
634
|
+
else:
|
|
635
|
+
os.makedirs(act.target, exist_ok=True)
|
|
636
|
+
log.append(f"mkdir -p {act.target} [done]")
|
|
637
|
+
continue
|
|
638
|
+
|
|
639
|
+
if act.kind in (LINK_HARDLINK, LINK_SYMLINK):
|
|
640
|
+
status = _existing_target_status(act)
|
|
641
|
+
if status == "ok":
|
|
642
|
+
log.append(f"ok (exists): {act.kind} {act.target} -> {act.source}")
|
|
643
|
+
continue
|
|
644
|
+
if status == "wrong":
|
|
645
|
+
raise LayoutApplyError(
|
|
646
|
+
f"refusing to replace {act.target!r}: it already exists and "
|
|
647
|
+
f"does NOT point at {act.source!r}",
|
|
648
|
+
hint="remove or fix the existing path by hand, then re-apply",
|
|
649
|
+
)
|
|
650
|
+
# absent -> create
|
|
651
|
+
if dry_run:
|
|
652
|
+
log.append(f"{act.kind} {act.target} -> {act.source}")
|
|
653
|
+
continue
|
|
654
|
+
try:
|
|
655
|
+
if act.kind == LINK_HARDLINK:
|
|
656
|
+
os.link(act.source, act.target)
|
|
657
|
+
else:
|
|
658
|
+
os.symlink(act.source, act.target)
|
|
659
|
+
except OSError as e:
|
|
660
|
+
hint = (
|
|
661
|
+
"hardlinks require the same filesystem/volume; if the file "
|
|
662
|
+
"and the layouts dir are on different volumes, re-plan for a "
|
|
663
|
+
"symlink"
|
|
664
|
+
if act.kind == LINK_HARDLINK
|
|
665
|
+
else "symlinks may need elevated privileges (Windows Admin / "
|
|
666
|
+
"Developer Mode)"
|
|
667
|
+
)
|
|
668
|
+
raise LayoutApplyError(
|
|
669
|
+
f"{act.kind} failed for {act.target!r}: {e}", hint=hint
|
|
670
|
+
)
|
|
671
|
+
log.append(f"{act.kind} {act.target} -> {act.source} [done]")
|
|
672
|
+
continue
|
|
673
|
+
|
|
674
|
+
raise LayoutApplyError(f"unknown action kind: {act.kind!r}")
|
|
675
|
+
|
|
676
|
+
return log
|