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