"""The link engine. Deployment is a pure function from (enabled mods, load order) to a desired `dest -> source` map, diffed against the last manifest and applied transactionally. Safety rules (this writes into a ~100 GB game install): 1. Never delete anything that is not in our manifest *and* still matches what we recorded. 2. Never delete a PAK we did not create (falls out of rule 1). 3. Every destructive operation is preceded by a Plan the caller can show the user. 4. reframework/data/ is never touched. """ from __future__ import annotations import os import shutil import uuid from dataclasses import dataclass, field from pathlib import Path, PurePosixPath from typing import Callable from . import paks from .layout import PAK, Entry from .paths import Paths from .util import atomic_write_json, read_json MANIFEST_VERSION = 1 ALLOWED_ROOTS = ("natives", "reframework", "pak_mods") class DeployError(Exception): pass @dataclass(frozen=True) class DeployMod: id: str entries: tuple[Entry, ...] @dataclass(frozen=True) class Item: dest: str src: Path mod: str kind: str @dataclass class Conflict: key: str # dest path, or a description for PAK asset overlaps kind: str # loose | pak-pak | pak-loose mods: list[str] # every mod involved, ascending priority winner: str losers: list[str] count: int = 1 # number of overlapping assets (PAK overlaps are aggregated) @dataclass class Desired: items: dict[str, Item] = field(default_factory=dict) conflicts: list[Conflict] = field(default_factory=list) slots: list[paks.Slot] = field(default_factory=list) # -- destination validation --------------------------------------------------- def validate_dest(dest: str, baseline_names: set[str] = frozenset()) -> str | None: """Return an error string if `dest` is not somewhere we may write, else None.""" if not dest or dest.startswith("/") or "\\" in dest or "\0" in dest: return f"invalid destination {dest!r}" parts = PurePosixPath(dest).parts if any(p in ("..", ".") for p in parts): return f"destination escapes the game directory: {dest}" low = [p.lower() for p in parts] if low[0] == "reframework" and len(low) > 1 and low[1] == "data": return f"reframework/data is never managed: {dest}" if len(parts) == 1: if dest in baseline_names: return f"refusing to shadow vanilla PAK {dest}" if paks.parse_pak_name(dest) or dest.lower().endswith(".dll"): return None return f"root-level file not allowed: {dest}" if low[0] not in ALLOWED_ROOTS: return f"destination outside natives/ or reframework/: {dest}" return None # -- desired state -------------------------------------------------------------- def build_desired(mods: list[DeployMod], baseline: paks.Baseline, hashes: Callable[[Path], frozenset] = paks.pak_hashes) -> Desired: """`mods` is in load order, lowest priority first; later mods win contested paths.""" des = Desired() by_key: dict[str, list[Item]] = {} for mod in mods: for e in mod.entries: if e.kind == PAK: continue by_key.setdefault(e.dest.lower(), []).append(Item(e.dest, e.src, mod.id, e.kind)) for contenders in by_key.values(): # a mod listing the same dest twice is not a conflict with itself winner = contenders[-1] des.items[winner.dest] = winner ids = list(dict.fromkeys(c.mod for c in contenders)) if len(ids) > 1: des.conflicts.append(Conflict(winner.dest, "loose", ids, winner.mod, [i for i in ids if i != winner.mod])) des.slots = paks.allocate([(m.id, list(m.entries)) for m in mods], baseline) for s in des.slots: des.items[s.dest] = Item(s.dest, s.src, s.mod_id, PAK) des.conflicts.extend(_pak_conflicts(des, mods, hashes)) des.conflicts.sort(key=lambda c: (c.kind, c.key.lower())) return des def _pak_conflicts(des: Desired, mods: list[DeployMod], hashes) -> list[Conflict]: """Asset overlaps involving PAKs, matched by KPKA path hash (best effort).""" if not des.slots: return [] order = {m.id: i for i, m in enumerate(mods)} owners: dict[tuple[int, int], list[tuple[str, str]]] = {} # hash -> [(mod, "pak"|"loose")] for s in des.slots: for h in hashes(s.src): owners.setdefault(h, []).append((s.mod_id, "pak")) if not owners: return [] for it in des.items.values(): if it.kind != PAK and it.dest.lower().startswith("natives/"): h = paks.path_hash(it.dest) if h in owners: owners[h].append((it.mod, "loose")) groups: dict[tuple, dict] = {} for h, lst in owners.items(): ids = tuple(sorted(dict.fromkeys(m for m, _ in lst), key=lambda m: order.get(m, 0))) if len(ids) < 2: continue loose = {m for m, k in lst if k == "loose"} g = groups.setdefault((("pak-loose" if loose else "pak-pak"), ids), {"count": 0, "loose": set()}) g["count"] += 1 g["loose"] |= loose out = [] for (kind, ids), g in groups.items(): # the loose file loader takes precedence over PAK contents winner = next((m for m in reversed(ids) if m in g["loose"]), ids[-1]) out.append(Conflict(f"{g['count']} shared asset(s)", kind, list(ids), winner, [i for i in ids if i != winner], g["count"])) return out # -- plan --------------------------------------------------------------------------- @dataclass class Action: op: str # add | remove | forget | skip dest: str item: Item | None = None entry: dict | None = None note: str = "" shadow: bool = False # add: an unmanaged file occupies dest and will be backed up def describe(self) -> str: if self.op == "add": extra = " (backs up existing file)" if self.shadow else "" return f"+ {self.dest}{extra}" if self.op == "remove": return f"- {self.dest}" + (" (restores backup)" if self.entry and self.entry.get("backup") else "") if self.op == "forget": return f"? {self.dest} {self.note}" return f"! {self.dest} {self.note}" @dataclass class Plan: actions: list[Action] = field(default_factory=list) warnings: list[str] = field(default_factory=list) @property def changes(self) -> list[Action]: return [a for a in self.actions if a.op in ("add", "remove")] @property def is_empty(self) -> bool: return not self.actions def describe(self) -> list[str]: return [a.describe() for a in self.actions] + [f"warning: {w}" for w in self.warnings] @dataclass class ApplyResult: added: int = 0 removed: int = 0 warnings: list[str] = field(default_factory=list) # -- engine --------------------------------------------------------------------------- class _Txn: """Undo log so a mid-apply failure leaves the game directory as it was.""" def __init__(self): self.undo: list[Callable[[], None]] = [] self.trash: list[Path] = [] def rollback(self) -> list[str]: errs = [] for fn in reversed(self.undo): try: fn() except Exception as e: # keep unwinding errs.append(str(e)) return errs class Deployer: def __init__(self, game_dir: Path, paths: Paths, link_mode: str = "symlink"): self.game = Path(game_dir) self.paths = paths self.link_mode = link_mode if not self.game.is_dir(): raise DeployError(f"game directory not found: {self.game}") self._real_game = Path(os.path.realpath(self.game)) # -- manifest --------------------------------------------------------- def manifest(self) -> dict: m = read_json(self.paths.manifest_file, None) if not isinstance(m, dict) or "entries" not in m: return {"version": MANIFEST_VERSION, "entries": {}, "dirs": []} m.setdefault("dirs", []) return m def _save_manifest(self, m: dict) -> None: atomic_write_json(self.paths.manifest_file, m) def _inside(self, path: Path) -> bool: real = Path(os.path.realpath(path)) return real == self._real_game or self._real_game in real.parents def _p(self, dest: str) -> Path: return self.game / dest def check(self, dest: str, entry: dict) -> str: """'ok' if the on-disk state still matches what we recorded, 'missing', or 'modified'.""" p = self._p(dest) if not os.path.lexists(p): return "missing" try: mode = entry["mode"] if mode == "symlink": return "ok" if os.path.islink(p) and os.readlink(p) == entry["src"] else "modified" if os.path.islink(p) or not os.path.isfile(p): return "modified" st = os.stat(p) if mode == "hardlink": return "ok" if (st.st_ino, st.st_dev) == (entry.get("ino"), entry.get("dev")) else "modified" return "ok" if (st.st_size, st.st_mtime_ns) == (entry.get("size"), entry.get("mtime_ns")) else "modified" except (OSError, KeyError): return "modified" def owned_paks(self) -> set[str]: m = self.manifest() return {d for d, e in m["entries"].items() if e.get("kind") == PAK and self.check(d, e) == "ok"} def desired_from_manifest(self, exclude_kind: str | None = None) -> Desired: """The currently-deployed state as a Desired, optionally minus one kind.""" des = Desired() for d, e in self.manifest()["entries"].items(): if e.get("kind") != exclude_kind: des.items[d] = Item(d, Path(e["src"]), e.get("mod", ""), e.get("kind", "")) return des # -- planning ----------------------------------------------------------- def plan(self, desired: Desired) -> Plan: plan = Plan() m = self.manifest() entries: dict[str, dict] = m["entries"] baseline = paks.load_baseline(self.paths.baseline_file) vanilla = {n for c in (baseline.chains.values() if baseline else []) for n in c.get("files", {})} for dest, entry in sorted(entries.items()): state = self.check(dest, entry) want = desired.items.get(dest) if want is None: if state == "ok": plan.actions.append(Action("remove", dest, entry=entry)) elif state == "missing": plan.actions.append(Action("forget", dest, entry=entry, note="already gone")) else: plan.actions.append(Action("forget", dest, entry=entry, note="changed since deployment; left in place")) plan.warnings.append(f"{dest} was modified outside the manager; leaving it alone") elif state == "modified": plan.actions.append(Action("skip", dest, want, entry, "changed since deployment; not overwritten")) plan.warnings.append(f"{dest} was modified outside the manager; skipping") elif str(want.src) != entry["src"] or want.mod != entry.get("mod"): plan.actions.append(Action("remove", dest, entry=entry, note="replaced")) plan.actions.append(Action("add", dest, want)) elif state == "missing": plan.actions.append(Action("add", dest, want, note="re-link")) for dest, item in sorted(desired.items.items()): if dest in entries: continue err = validate_dest(dest, vanilla) if err: plan.actions.append(Action("skip", dest, item, note=err)) plan.warnings.append(err) continue p = self._p(dest) shadow = os.path.lexists(p) if shadow and (os.path.isdir(p) and not os.path.islink(p)): plan.actions.append(Action("skip", dest, item, note="a directory is in the way")) plan.warnings.append(f"{dest}: a directory is in the way") continue if shadow and (self.paths.backup_dir / dest).exists(): plan.actions.append(Action("skip", dest, item, note="a stale backup already exists")) plan.warnings.append(f"{dest}: stale backup exists at {self.paths.backup_dir / dest}") continue plan.actions.append(Action("add", dest, item, shadow=shadow)) return plan # -- apply -------------------------------------------------------------- def apply(self, plan: Plan) -> ApplyResult: m = self.manifest() entries: dict[str, dict] = dict(m["entries"]) dirs: list[str] = list(m["dirs"]) res = ApplyResult(warnings=list(plan.warnings)) txn = _Txn() try: # removals first so a replaced path is free before its new link goes in carry: dict[str, str] = {} for a in plan.actions: if a.op == "remove": replaced = a.note == "replaced" old = entries.pop(a.dest) self._remove(a.dest, old, txn, restore=not replaced) if replaced and old.get("backup"): carry[a.dest] = old["backup"] # the shadowed original stays backed up else: res.removed += 1 elif a.op == "forget": e = entries.pop(a.dest, None) if a.note == "already gone" and e and e.get("backup"): self._restore_backup(a.dest, txn) for a in plan.actions: if a.op == "add": entries[a.dest] = self._add(a, txn, dirs, carry.get(a.dest, "")) res.added += 1 m = {"version": MANIFEST_VERSION, "entries": entries, "dirs": dirs} self._save_manifest(m) except BaseException as e: errs = txn.rollback() if errs: res.warnings.extend(f"rollback: {x}" for x in errs) if isinstance(e, (OSError, DeployError)): raise DeployError(f"deploy failed and was rolled back: {e}") from e raise for t in txn.trash: shutil.rmtree(t, ignore_errors=True) self._prune_dirs(m) return res def undeploy_all(self) -> ApplyResult: return self.apply(self.plan(Desired())) # -- primitives ----------------------------------------------------------- def _remove(self, dest: str, entry: dict, txn: _Txn, restore: bool = True) -> None: p = self._p(dest) mode = entry["mode"] if mode == "symlink": target = os.readlink(p) os.unlink(p) txn.undo.append(lambda: os.symlink(target, p)) elif mode == "hardlink": src = entry["src"] os.unlink(p) txn.undo.append(lambda: os.link(src, p)) else: stash = self.paths.trash_dir / uuid.uuid4().hex stash.mkdir(parents=True) txn.trash.append(stash) shutil.move(str(p), str(stash / "f")) txn.undo.append(lambda: shutil.move(str(stash / "f"), str(p))) if restore and entry.get("backup"): self._restore_backup(dest, txn) def _restore_backup(self, dest: str, txn: _Txn) -> None: bak = self.paths.backup_dir / dest p = self._p(dest) if not os.path.lexists(bak): return if os.path.lexists(p): return # something else lives there now; keep the backup os.makedirs(p.parent, exist_ok=True) shutil.move(str(bak), str(p)) txn.undo.append(lambda: (os.makedirs(bak.parent, exist_ok=True), shutil.move(str(p), str(bak)))) self._prune_backup_dirs(bak.parent) def _prune_backup_dirs(self, d: Path) -> None: root = self.paths.backup_dir while d != root and root in d.parents: try: d.rmdir() except OSError: return d = d.parent def _mkdirs(self, rel_parent: str, dirs: list[str], txn: _Txn) -> None: cur = self.game rel = "" for part in PurePosixPath(rel_parent).parts: cur = cur / part rel = f"{rel}/{part}" if rel else part if os.path.lexists(cur): if not os.path.isdir(cur): raise DeployError(f"{rel} exists and is not a directory") continue if not self._inside(cur.parent): raise DeployError(f"{rel} would land outside the game directory") os.mkdir(cur) dirs.append(rel) txn.undo.append(lambda c=cur: os.rmdir(c)) def _add(self, a: Action, txn: _Txn, dirs: list[str], carry_backup) -> dict: assert a.item is not None dest, src = a.dest, a.item.src err = validate_dest(dest) if err: raise DeployError(err) p = self._p(dest) if not os.path.isfile(src): raise DeployError(f"source file missing: {src}") rel_parent = str(PurePosixPath(dest).parent) if rel_parent != ".": self._mkdirs(rel_parent, dirs, txn) if not self._inside(p.parent): raise DeployError(f"{dest} would land outside the game directory") backup = carry_backup or "" if os.path.lexists(p): bak = self.paths.backup_dir / dest if os.path.lexists(bak): raise DeployError(f"stale backup exists: {bak}") os.makedirs(bak.parent, exist_ok=True) shutil.move(str(p), str(bak)) txn.undo.append(lambda: shutil.move(str(bak), str(p))) backup = dest mode = self._link(src, p, txn) rec = {"mod": a.item.mod, "src": str(src), "mode": mode, "kind": a.item.kind, "backup": backup} if mode != "symlink": st = os.stat(p) rec.update(size=st.st_size, mtime_ns=st.st_mtime_ns, ino=st.st_ino, dev=st.st_dev) return rec def _link(self, src: Path, dest: Path, txn: _Txn) -> str: mode = self.link_mode src_abs = os.path.abspath(src) if mode == "symlink": os.symlink(src_abs, dest) elif mode == "hardlink": try: os.link(src_abs, dest) except OSError: shutil.copy2(src_abs, dest) mode = "copy" else: shutil.copy2(src_abs, dest) txn.undo.append(lambda: os.unlink(dest)) return mode def _prune_dirs(self, m: dict) -> None: """Remove directories we created that are now empty (deepest first).""" keep = [] for rel in sorted(m["dirs"], key=lambda r: -r.count("/")): p = self._p(rel) if not os.path.isdir(p): continue try: p.rmdir() except OSError: keep.append(rel) if len(keep) != len(m["dirs"]): m["dirs"] = sorted(keep) self._save_manifest(m)