diff --git a/docs/NIX.md b/docs/NIX.md new file mode 100644 index 0000000..853d844 --- /dev/null +++ b/docs/NIX.md @@ -0,0 +1,229 @@ +# Nix setup + +The experimental Nix package provides a rootless build and launch environment +for x86_64 Linux. It supplies the dependencies, not a prebuilt Darling runtime +or game files. Initial setup builds Darling in a writable per-user workspace. + +## Prerequisites + +Enable Nix flakes and configure subordinate UID/GID ranges for your user. On +NixOS, add this to your system configuration and rebuild: + +```nix +users.users..autoSubUidGidRange = true; +``` + +On other Linux distributions, [install Nix using the official instructions](https://nixos.org/download/). +You do not need to install NixOS. If you already use `nix-shell`, Nix is installed. +Enable the modern CLI and flakes in `~/.config/nix/nix.conf`: + +```ini +experimental-features = nix-command flakes +``` + +Alternatively, enable them for an individual command: + +```sh +nix --extra-experimental-features 'nix-command flakes' run github:lucannez64/darling-lol +``` + +The rootless launcher requires unprivileged user namespaces, subordinate UID/GID +ranges, and working `newuidmap`/`newgidmap` helpers. If these are already configured +on your distribution, no additional host setup is needed. + +Run setup in an interactive terminal and allow roughly 40–50 GB for the runtime +build. + +## Install and launch + +Run the flake directly from GitHub; no local checkout is needed: + +```sh +nix run github:lucannez64/darling-lol +``` + +On first launch, setup prompts to build Darling and install the Riot client. +To explicitly open the installer, including for another region: + +```sh +nix run github:lucannez64/darling-lol#installer +``` + +Setup prompts before building. Subsequent launches reuse a compatible runtime; +they do not automatically rebuild it. If the package is installed in your +profile or system configuration, the equivalent commands are +`darling-lol --installer` and `darling-lol`. The desktop entry opens a terminal +for prompts and session output. + +Keep the terminal open while clients are running and until cleanup completes. +Default and installer sessions wait for clients to exit, then shut Darling down +in the same namespace. A failed shutdown is retried without discarding that +namespace. + +## Install permanently + +`nix run` does not add commands to your profile or install a desktop menu entry. +It does reuse downloaded store paths and the persistent Darling workspace. +For commands on your `PATH` and desktop-menu access, install the package using +one of the following methods. + +### User profile (NixOS or other Linux distributions) + +```sh +nix profile add github:lucannez64/darling-lol#default +``` + +Then launch from your application menu or terminal: + +```sh +darling-lol +# Explicit setup or runtime shell: +darling-lol --installer +darling-lol-shell +``` + +If the desktop entry does not appear, ensure your desktop session includes +`~/.nix-profile/share` in `XDG_DATA_DIRS`, then log out and back in. Non-NixOS +systems may need this desktop-session integration after installing Nix. + +To upgrade, find the package's name with `nix profile list`, then run +`nix profile upgrade `. Close the clients first; after upgrading, run +`darling-lol --update` to rebuild the mutable runtime when required. + +### NixOS system configuration + +In your system flake, add the input and pass it to your NixOS modules: + +```nix +inputs.darling-lol.url = "github:lucannez64/darling-lol"; + +outputs = inputs@{ nixpkgs, ... }: { + nixosConfigurations. = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + specialArgs = { inherit inputs; }; + modules = [ ./configuration.nix ]; + }; +}; +``` + +Merge these entries into your existing flake rather than replacing its outputs. +In `configuration.nix`, add the package and subordinate-ID configuration: + +```nix +{ inputs, pkgs, ... }: +{ + environment.systemPackages = [ + inputs.darling-lol.packages.${pkgs.stdenv.hostPlatform.system}.default + ]; + users.users..autoSubUidGidRange = true; + nix.settings.experimental-features = [ "nix-command" "flakes" ]; +} +``` + +Apply with `sudo nixos-rebuild switch --flake .#`. The launcher and +desktop entry are then installed system-wide; each user still has a separate +workspace and prefix. Start `darling-lol` as your normal user, not with sudo. + +To upgrade, update the input with `nix flake update darling-lol`, rebuild your +system, and run `darling-lol --update` after closing the clients. + +## Update + +Close League and both Riot clients and wait for the launcher to finish cleanup. +Then run the updated flake and rebuild the runtime: + +```sh +nix run --refresh github:lucannez64/darling-lol -- --update +# Installed package: darling-lol --update +``` + +This rebuilds incrementally, installs the base runtime into the user-owned +workspace, and refreshes the per-prefix overlays. No `sudo` is needed. + +## Local checkout and scripts + +You can also launch the flake directly from an already checked-out repository: + +```sh +cd /path/to/darling-lol +nix run . +``` + +Or clone it first: + +```sh +git clone https://github.com/lucannez64/darling-lol.git +cd darling-lol +nix run . +``` + +The same checkout also provides installer and update commands: + +```sh +nix run .#installer +# After updating the checkout: +nix run . -- --update +``` + +These commands work on NixOS and other Linux distributions with Nix installed. +Use `nix run` for this flake rather than `nix-shell`: the launcher sets up the +rootless namespace in addition to supplying dependencies. `nix develop` is for +development tools only; use `nix run .#shell` for an actual runtime shell. + +To run a repository script inside the rootless FHS environment, pass its command +after `--`. Commands run from the managed workspace, using the tools copied from +the selected flake: + +```sh +nix run github:lucannez64/darling-lol -- python3 scripts/validate.py +# Equivalent from a local checkout: +nix run . -- python3 scripts/validate.py +``` + +Use the supported installer/update commands for maintenance. Arbitrary script +commands do not get the default launcher's automatic client-session cleanup; if +a script starts Darling, shut it down in the same namespace before returning. +For multiple commands, use an interactive runtime shell: + +## Runtime shell + +```sh +nix run github:lucannez64/darling-lol#shell +# Local checkout: nix run .#shell +# Installed package: darling-lol-shell +``` + +Use the launcher-provided shell rather than entering the raw FHS environment. +If you start Darling manually in this shell, run `darling shutdown` there +before exiting. A separately launched shell cannot reconnect to an existing +Darling namespace. + +## Files and operational limits + +- Workspace: `${XDG_DATA_HOME:-$HOME/.local/share}/darling-lol`. +- Default guest prefix: `$HOME/.darling`; set `DPREFIX` to use another prefix. + Explicit `DPREFIX` and `RIOT_GUEST_HOME` overrides take precedence over saved + configuration, so clear stale overrides before switching installations. +- Package upgrades refresh managed tools and remove previously tracked obsolete + files. Builds, the installed runtime, guest prefixes, and unmanaged files are + preserved. Edit the source checkout rather than copied workspace tools. +- One active session is supported per workspace. A workspace lock and live + prefix marker prevent concurrent launches; the launcher does not kill an + existing session or reconnect across namespaces. Inspect an unexpected live + marker rather than deleting it to force a launch. +- Readiness records in `build/nix-ready/` validate the selected prefix, guest, + package, base runtime, and required overlays. A new prefix can reuse a + compatible base runtime; failed setup invalidates readiness. +- Keep `build/nix-gcroots/` while using the workspace. Mutable compiled binaries + can reference Nix-store libraries, so these roots retain the package closures + they need. Remove them only when discarding the dependent build outputs. + +The launcher uses subordinate-ID user namespaces before entering the FHS +environment. Mount privileges are scoped to that namespace, not host root; +workspace and prefix files retain the Linux user's ownership. The setup does +not install a host setuid binary or require a privileged runtime installation. + +Darling compilation still happens outside Nix derivations and uses the host CPU; +the resulting runtime is not a portable, binary-cacheable Nix package. The +FFmpeg dependency is pinned to 8.x because the generated AV wrappers require +its library ABI. diff --git a/docs/SETUP.md b/docs/SETUP.md index e0a7b18..5998a08 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -1,73 +1,33 @@ # Setup and portability -## NixOS package + -Use `darling-lol`, `darling-lol --installer`, or `darling-lol-shell` rather than -invoking the raw FHS environment. The launcher uses `unshare --map-auto` before -bubblewrap, so the user needs subordinate UID/GID ranges (`/etc/subuid` and -`/etc/subgid`, or NixOS `users.users..autoSubUidGidRange = true`). -Namespace root maps to a subordinate host ID, not host root. The FHS entrypoint -restores the real Linux user's IDs while retaining namespace-only capabilities; -the Darling shim temporarily switches effective IDs for mounts. No `sudo`, -root-owned binary in a writable workspace, or host setuid installation is needed. +## Nix package -The launcher refreshes managed scripts with executable bits and owner-write access, -rejecting symlinks in that managed tree. It preserves `build/`, `usr-local/`, and -the guest prefix. Edit the source checkout, not copied workspace tools. +See [prerequisites](NIX.md#prerequisites) before starting, and the +[Nix setup guide](NIX.md) for local checkout usage, updates, scripts, and runtime +details. -First setup: run `darling-lol --installer` in an interactive terminal, choose the -build when prompted, then choose the Riot region. The desktop entry uses -`Terminal=true` and delegates terminal selection to the desktop environment. -A launch with EOF stdin fails with a command to run in a terminal; it does not -try a hard-coded list of terminal emulators or silently abandon a prompt. +Run the experimental rootless package on x86_64 Linux: -Update: after manually exiting League and both Riot clients, shut down Darling -in the ORIGINAL `darling-lol-shell` terminal with `darling shutdown`, then exit -that shell. Upgrade the Nix package and run `darling-lol --update` in a terminal. -This rebuilds incrementally, installs into the user-owned `/usr/local` bind, -and installs paired per-prefix overlays without sudo. For a first-time setup, -the same rootless update path performs the base build/install. Native/manual -(non-Nix) instructions below still use their normal privileged base install. +```sh +nix --extra-experimental-features 'nix-command flakes' run github:lucannez64/darling-lol +``` -Readiness JSON under `build/nix-ready/` is keyed by canonical prefix and checks -the configured guest and resolved host guest home, UID/GID, package path, -maintained patch/tool identities, executable modes, base SDK/runtime metadata -and required-overlay inventory. Stamping reuses required-artifact validation. -Changing `RIOT_GUEST_HOME` invalidates readiness. Explicit `DPREFIX` and -`RIOT_GUEST_HOME` environment overrides take precedence over saved configuration; -clear stale values before selecting a different installation. A separate base -manifest avoids rebuilding when only a new prefix needs overlays. -Updates invalidate readiness before replacing the base; failed/interrupted setup -cannot leave the previous prefix stamp authoritative. Existing app bundles never -bypass these checks. Normal ready launches only check state, not rebuild. +To install permanently, adding the launcher to your user profile: -Operational limits: each workspace supports one foreground namespace session. -The launcher holds a lock while its child runs, even if bubblewrap closes file -descriptors. It refuses a live `.init.pid` rather than reconnecting across new -user namespaces or killing anything. A stale/reused live PID can therefore block -a launch conservatively; inspect it before taking manual action. Never remove a -marker just to force another launch. Manual stop/reconnect and game lifecycle -acceptance have not been exercised with this repaired package. Keep the original -shell open if you need to stop a session. Maintenance requires that prefix idle; -rootless CMake's shutdown hook checks only that prefix, never enumerates and -shuts down every user's launchd. The inherited scoped stop helper remains used -by overlay installers; it is fixture-tested, not live-game tested here. +```sh +nix profile add github:lucannez64/darling-lol#default +``` -Mutable Linux ELF outputs can retain Nix-store interpreter/library references. -A native compiler probe executed successfully with its store-path interpreter; -there is no evidence that all such outputs require `patchelf`. The update path -creates indirect closure roots under `build/nix-gcroots/` before compiling and -retains old package roots for incremental outputs. Keep those roots while using -the workspace; remove them only when discarding/rebuilding all dependent mutable -outputs. This trades disk retention for GC safety, not a claim of full runtime -relocatability. A full Darling build/upgrade and GC acceptance test remains open. +Then run `darling-lol` or use the desktop application menu. See +[permanent installation](NIX.md#install-permanently) for details. -The FFmpeg dependency is explicitly 8.x: generated Darling AV wrappers reference -`libavformat.so.62`, `libavcodec.so.62`, and `libavutil.so.60`; FFmpeg 9 cannot -replace those ABI versions. - -Run the package regression suite with `python3 -m unittest discover -s tests -v`. +First launch builds Darling and guides you through Riot client installation. +NixOS is not required; other Linux distributions with Nix installed are also +supported. +## Native setup notes The patch manifest pins Darling to `60ba801decee7a00782f74f6be4c8ffb013f79ff` and records every changed submodule's revision. `scripts/prepare.py` checks each revision before applying patches and never resets an existing checkout to make a patch apply. Re-running it accepts an already-applied patch. diff --git a/flake.nix b/flake.nix index 4fe1a74..54f5ec8 100644 --- a/flake.nix +++ b/flake.nix @@ -10,6 +10,7 @@ let system = "x86_64-linux"; pkgs = nixpkgs.legacyPackages.${system}; + llvmPackages = pkgs.llvmPackages_21; # Python environment with dependencies required by tooling pythonEnv = pkgs.python3.withPackages ( @@ -36,10 +37,9 @@ gnumake ninja pkg-config - clang - lld - llvm - llvm.dev + llvmPackages.clang + llvmPackages.lld + llvmPackages.llvm llvmPackages.clang-unwrapped bison flex @@ -54,16 +54,11 @@ # System & core libraries glibc - glibc.dev glibc.static zlib - zlib.dev libffi - libffi.dev libbsd - libbsd.dev libmd - libmd.dev libcap libelf fuse @@ -131,8 +126,7 @@ darlingLolFhs = pkgs.buildFHSEnv { name = "darling-lol-fhs"; targetPkgs = pkgs: buildDeps; - multiPkgs = pkgs: [ pkgs.glibc ]; - + # multiArch is disabled; glibc is already in targetPkgs and the FHS base. extraOutputsToInstall = [ "dev" "out" ]; extraBwrapArgs = [ @@ -143,44 +137,18 @@ ]; extraBuildCommands = '' - mkdir -p bin usr/bin - rm -f bin/clang bin/clang++ usr/bin/clang usr/bin/clang++ - ln -sfn /usr-local $out/usr/local - cat << 'EOF' > usr/bin/clang -#!/usr/bin/env bash -is_darwin=0 -for a in "$@"; do - if [[ "$a" == *darwin* ]] || [[ "$a" == *apple* ]] || [[ "$a" == *macosx* ]]; then - is_darwin=1 - break - fi -done -if [[ "$is_darwin" -eq 1 ]]; then - exec ${pkgs.llvmPackages.clang-unwrapped}/bin/clang "$@" -else - exec ${pkgs.clang}/bin/clang -idirafter /usr/include "$@" -fi -EOF - chmod +x usr/bin/clang - - cat << 'EOF' > usr/bin/clang++ -#!/usr/bin/env bash -is_darwin=0 -for a in "$@"; do - if [[ "$a" == *darwin* ]] || [[ "$a" == *apple* ]] || [[ "$a" == *macosx* ]]; then - is_darwin=1 - break - fi -done -if [[ "$is_darwin" -eq 1 ]]; then - exec ${pkgs.llvmPackages.clang-unwrapped}/bin/clang++ "$@" -else - exec ${pkgs.clang}/bin/clang++ -idirafter /usr/include "$@" -fi -EOF - chmod +x usr/bin/clang++ - cp -f usr/bin/clang bin/clang - cp -f usr/bin/clang++ bin/clang++ + mkdir -p "$out/usr/bin" + rm -f "$out/usr/bin/clang" "$out/usr/bin/clang++" + ln -sfn /usr-local "$out/usr/local" + { + printf '%s\n' '#!/bin/sh' \ + 'wrapped=${llvmPackages.clang}/bin' \ + 'unwrapped=${llvmPackages.clang-unwrapped}/bin' + cat ${./scripts/nix-compiler.sh} + } > "$out/usr/bin/clang" + chmod +x "$out/usr/bin/clang" + ln -s clang "$out/usr/bin/clang++" + # /bin is already a guest-absolute symlink to /usr/bin. ''; profile = '' diff --git a/scripts/nix-compiler.sh b/scripts/nix-compiler.sh new file mode 100644 index 0000000..91178c6 --- /dev/null +++ b/scripts/nix-compiler.sh @@ -0,0 +1,8 @@ +# Invoked under clang or clang++: keep the driver's original name. +driver=${0##*/} +for arg in "$@"; do + case "$arg" in + *darwin*|*apple*|*macosx*) exec "$unwrapped/$driver" "$@" ;; + esac +done +exec "$wrapped/$driver" -idirafter /usr/include "$@" diff --git a/scripts/nix-copy.py b/scripts/nix-copy.py index 17874cb..a7eb2bd 100644 --- a/scripts/nix-copy.py +++ b/scripts/nix-copy.py @@ -1,44 +1,178 @@ #!/usr/bin/env python3 -"""Refresh managed package files without following mutable destination symlinks.""" +"""Refresh tracked package files, never recursively deleting workspace data. + +Legacy workspaces have no ownership inventory: adopt only files in the current +package, preserving unknown legacy files rather than guessing what is disposable. +The manifest is untrusted input, not authority to delete arbitrary workspace paths. +This guards pre-existing links; concurrent hostile workspace mutation is unsupported. +""" +import errno +import json import os -from pathlib import Path +from pathlib import Path, PurePosixPath import shutil import stat import sys import tempfile -source, destination = map(Path, sys.argv[1:]) -items = 'docs patches research scripts tests tools upstream LICENSE NOTICE.md README.md requirements.txt'.split() -# Preflight the entire managed tree before writing anything. Never chmod or copy -# through a link planted by a previous installation (including directory links). -for root in [destination, *destination.parents]: - if root.is_symlink(): - raise SystemExit(f'Refusing symlink workspace ancestor: {root}') -for item in items: - for base in [source / item, destination / item]: - for path in [base, *base.rglob('*')] if base.is_dir() else [base]: - if path.is_symlink(): - raise SystemExit(f'Refusing symlink in managed package tree: {path}') - if path.exists() and not (path.is_file() or path.is_dir()): - raise SystemExit(f'Refusing special file in managed package tree: {path}') -for item in items: - src = source / item - if not src.exists(): - continue - for path in [src, *src.rglob('*')] if src.is_dir() else [src]: - target = destination / path.relative_to(source) - if path.is_dir(): - target.mkdir(parents=True, exist_ok=True) - target.chmod(0o755) +ITEMS = 'docs patches research scripts tests tools upstream LICENSE NOTICE.md README.md requirements.txt'.split() +DIRECTORY_ITEMS = set(ITEMS[:7]) +MANIFEST = '.nix-managed-files.json' + + +def refuse(message): + raise ValueError(message) + + +def check_ancestors(path): + for ancestor in [path, *path.parents]: + if ancestor.is_symlink(): + refuse(f'Refusing symlink workspace ancestor: {ancestor}') + + +def inventory(root): + files, directories = set(), set() + + def visit(path): + mode = path.lstat().st_mode + relative = path.relative_to(root).as_posix() + if stat.S_ISREG(mode): + files.add(relative) + elif stat.S_ISDIR(mode): + directories.add(relative) + for child in sorted(path.iterdir()): + visit(child) else: - target.parent.mkdir(parents=True, exist_ok=True) - fd, temporary = tempfile.mkstemp(prefix='.package-', dir=target.parent) + refuse(f'Refusing symlink or special file in managed package tree: {path}') + + for item in ITEMS: + path = root / item + if path.exists() or path.is_symlink(): + if path.is_dir() != (item in DIRECTORY_ITEMS) and not path.is_symlink(): + refuse(f'Invalid managed root type: {path}') + visit(path) + return files, directories + + +def load_manifest(path): + if path.is_symlink(): + refuse(f'Refusing symlink manifest: {path}') + if not path.exists(): + return set(), set() + if not path.is_file(): + refuse(f'Refusing special manifest: {path}') + data = json.loads(path.read_text()) + if (not isinstance(data, dict) or set(data) != {'version', 'files', 'directories'} + or type(data['version']) is not int or data['version'] != 1): + refuse('Invalid managed-file manifest schema') + result = [] + for field in ('files', 'directories'): + entries = data[field] + if not isinstance(entries, list): + refuse(f'Invalid manifest {field}') + validated = set() + for name in entries: + if not isinstance(name, str): + refuse('Non-string manifest path') + parts = PurePosixPath(name).parts + if not parts: + refuse('Empty manifest path') + continue + if (name != '/'.join(parts) or '\\' in name or '\x00' in name + or any(part in ('.', '..', '/') for part in parts) + or parts[0] not in ITEMS + or (len(parts) > 1 and parts[0] not in DIRECTORY_ITEMS) + or (field == 'directories' and parts[0] not in DIRECTORY_ITEMS)): + refuse(f'Unsafe manifest path: {name!r}') + if name in validated: + refuse(f'Duplicate manifest path: {name}') + validated.add(name) + result.append(validated) + files, directories = result + if files & directories: + refuse('Conflicting manifest file/directory paths') + for name in files | directories: + if any(parent.as_posix() in files for parent in PurePosixPath(name).parents): + refuse(f'Manifest file used as directory: {name}') + return files, directories + + +def atomic_write(target, writer, mode): + fd, temporary = tempfile.mkstemp(prefix='.package-', dir=target.parent) + try: + with os.fdopen(fd, 'wb') as output: + writer(output) + os.chmod(temporary, mode) + os.replace(temporary, target) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def refresh(source, destination): + for root in (source, destination): + if '..' in root.parts: + refuse(f'Refusing path traversal: {root}') + check_ancestors(root) + source, destination = source.absolute(), destination.absolute() + if not source.is_dir(): + refuse(f'Missing package source: {source}') + if source == destination or source in destination.parents or destination in source.parents: + refuse('Package source and workspace must not overlap') + old_files, old_directories = load_manifest(destination / MANIFEST) + files, directories = inventory(source) + present_files, present_directories = inventory(destination) + + # Validate ownership/type conflicts before copying or pruning anything. + if old_files & present_directories or old_directories & present_files: + refuse('Tracked path changed type in workspace') + stale_files = old_files - files + stale_directories = old_directories - directories + if directories & (present_files - stale_files): + refuse('Unmanaged file blocks package directory') + replaced_directories = files & present_directories + for name in replaced_directories: + if name not in stale_directories: + refuse(f'Unmanaged directory blocks package file: {name}') + prefix = name + '/' + if (any(p.startswith(prefix) and p not in stale_files for p in present_files) + or any(p.startswith(prefix) and p not in stale_directories for p in present_directories)): + refuse(f'Unmanaged data blocks directory replacement: {name}') + + for name in sorted(stale_files): + if name in present_files: + (destination / name).unlink() + # rmdir only: a user-added child always keeps its containing tree alive. + for name in sorted(stale_directories, key=lambda p: (-p.count('/'), p)): + if name in present_directories: try: - with os.fdopen(fd, 'wb') as output, path.open('rb') as input_file: - shutil.copyfileobj(input_file, output) - # Keep executable bits, remove special bits, and keep owner writable. - os.chmod(temporary, stat.S_IMODE(path.stat().st_mode) & 0o777 | 0o600) - os.replace(temporary, target) - finally: - if os.path.exists(temporary): - os.unlink(temporary) + (destination / name).rmdir() + except OSError as error: + if error.errno not in (errno.ENOTEMPTY, errno.EEXIST): + raise + destination.mkdir(parents=True, exist_ok=True) + for name in sorted(directories, key=lambda p: (p.count('/'), p)): + target = destination / name + target.mkdir(parents=True, exist_ok=True) + target.chmod(0o755) + for name in sorted(files): + path, target = source / name, destination / name + target.parent.mkdir(parents=True, exist_ok=True) + + def copy(output): + with path.open('rb') as input_file: + shutil.copyfileobj(input_file, output) + + atomic_write(target, copy, (stat.S_IMODE(path.stat().st_mode) & 0o777) | 0o600) + state = json.dumps({'version': 1, 'files': sorted(files), + 'directories': sorted(directories)}, indent=2) + '\n' + atomic_write(destination / MANIFEST, lambda output: output.write(state.encode()), 0o600) + + +if __name__ == '__main__': + try: + if len(sys.argv) != 3: + refuse('Usage: nix-copy.py SOURCE DESTINATION') + refresh(*map(Path, sys.argv[1:])) + except (ValueError, OSError) as error: + raise SystemExit(f'Package refresh refused: {error}') diff --git a/scripts/nix-idle.py b/scripts/nix-idle.py index 8ce0dc0..b827ffd 100644 --- a/scripts/nix-idle.py +++ b/scripts/nix-idle.py @@ -3,34 +3,59 @@ import os from pathlib import Path -prefix = Path(os.environ.get('DPREFIX', str(Path.home() / '.darling'))).resolve() -try: - pid = int((prefix / '.init.pid').read_text().strip()) -except FileNotFoundError: - pid = 0 -except (OSError, ValueError): - raise SystemExit('Cannot validate prefix server marker; inspect it before maintenance.') -if pid > 0: - try: - status = (Path('/proc') / str(pid) / 'status').read_text() - if not any(line.startswith('State:') and line.split()[1] == 'Z' for line in status.splitlines()): - raise SystemExit('Prefix has a live server marker; exit Riot/League and run darling shutdown in the ORIGINAL namespace before updating. No process was stopped.') - except FileNotFoundError: - pass -# Only inspect same-user mappings under this exact prefix. An unrelated game's -# command name is not grounds to stop or modify it. Unreadable same-user maps -# fail closed because we cannot rule out a client still using these files. -paths = [str(prefix / name) + '/' for name in ( - 'Applications/Riot Client.app', 'Applications/League of Legends.app', - 'Users/Shared/Riot Games')] -for process in Path('/proc').iterdir(): - if not process.name.isdigit(): - continue - try: - if process.stat().st_uid != os.getuid(): + +def client_active(prefix, *, processes=None): + """Check exact-prefix mappings; only vanished processes may be skipped.""" + paths = [str(prefix / name) + '/' for name in ( + 'Applications/Riot Client.app', 'Applications/League of Legends.app', + 'Users/Shared/Riot Games')] + namespace_map = Path('/proc/self/uid_map').read_text() + for process in Path('/proc').iterdir() if processes is None else processes: + if not process.name.isdigit(): continue - mappings = (process / 'maps').read_text() - if any(path in mappings for path in paths): - raise SystemExit('A client maps this prefix; exit it before rootless maintenance. No process was stopped.') - except (FileNotFoundError, ProcessLookupError, PermissionError): - continue + try: + if process.stat().st_uid != os.getuid(): + continue + # The outer live-marker gate excludes earlier Darling namespaces. + # Host processes are not ours to inspect; their maps are commonly + # unreadable from a child user namespace (including systemd --user). + if processes is None and (process / 'uid_map').read_text() != namespace_map: + continue + mappings = (process / 'maps').read_text() + if any(path in mappings for path in paths): + return True + except (FileNotFoundError, ProcessLookupError): + # Process exited between enumeration and inspection. + continue + except OSError as error: + raise RuntimeError(f'Cannot inspect process {process.name}; cannot establish that the prefix is idle: {error}') from error + return False + + +def main(): + prefix = Path(os.environ.get('DPREFIX', str(Path.home() / '.darling'))).resolve() + try: + pid = int((prefix / '.init.pid').read_text().strip()) + except FileNotFoundError: + pid = 0 + except (OSError, ValueError): + raise SystemExit('Cannot validate prefix server marker; inspect it before maintenance.') + if pid > 0: + try: + status = (Path('/proc') / str(pid) / 'status').read_text() + if not any(line.startswith('State:') and line.split()[1] == 'Z' for line in status.splitlines()): + raise SystemExit('Prefix has a live server marker; exit Riot/League and run darling shutdown in the ORIGINAL namespace before updating. No process was stopped.') + except (FileNotFoundError, ProcessLookupError): + pass + except OSError as error: + raise SystemExit(f'Cannot validate prefix server marker: {error}') from error + try: + active = client_active(prefix) + except (OSError, RuntimeError) as error: + raise SystemExit(str(error)) from error + if active: + raise SystemExit('A client maps this prefix; exit it before rootless maintenance. No process was stopped.') + + +if __name__ == '__main__': + main() diff --git a/scripts/nix-lifecycle.py b/scripts/nix-lifecycle.py new file mode 100644 index 0000000..598ad00 --- /dev/null +++ b/scripts/nix-lifecycle.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Keep namespace-local shutdown authority until the foreground and clients exit. + +Never kill by name or use a marker PID as authority to signal a host process. +Uninspectable mappings retain this namespace rather than guessing it is idle. +""" +import importlib.util +import os +from pathlib import Path +import signal +import subprocess +import sys +import time + + +def main(): + scripts = Path(__file__).resolve().parent + spec = importlib.util.spec_from_file_location('nix_idle', scripts / 'nix-idle.py') + assert spec is not None and spec.loader is not None + idle = importlib.util.module_from_spec(spec) + spec.loader.exec_module(idle) + prefix = Path(os.environ['DPREFIX']).resolve() + child = None + interrupted = 0 + + def interrupted_by(signum, _frame): + nonlocal interrupted + interrupted = 128 + signum + # Only our own foreground child, never a marker PID or host-wide search. + if child is not None and child.poll() is None: + try: + child.send_signal(signum) + except ProcessLookupError: + pass + + for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(signum, interrupted_by) + result = 1 + try: + child = subprocess.Popen(sys.argv[1:]) + result = child.wait() + if result < 0: + result = 128 - result + except OSError as error: + print(f'Cannot start client: {error}', file=sys.stderr) + finally: + # A launcher can return while guest clients remain. Do not tear down + # their server, or discard the only namespace capable of shutting it down. + last_error = None + while True: + try: + if not idle.client_active(prefix): + break + last_error = None + except (OSError, RuntimeError) as error: + if str(error) != last_error: + print(f'{error}; retaining original namespace and retrying. ' + 'Exit clients to allow safe shutdown.', file=sys.stderr) + last_error = str(error) + time.sleep(1) + while True: + try: + shutdown = subprocess.run(['bash', str(scripts / 'darling-rootless.sh'), 'shutdown']).returncode + except OSError as error: + print(f'Cannot run namespace-local shutdown: {error}', file=sys.stderr) + shutdown = 1 + if not shutdown: + break + print(f'Darling namespace-local shutdown failed (status {shutdown}); ' + 'retaining the original namespace and retrying in 5 seconds. ' + 'Keep this terminal open.', file=sys.stderr) + time.sleep(5) + result = interrupted or result + return result + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/nix-session.sh b/scripts/nix-session.sh index 9626465..f9fca2d 100755 --- a/scripts/nix-session.sh +++ b/scripts/nix-session.sh @@ -27,6 +27,6 @@ if [ "${1:-}" = --installer ] || [ ! -d "$DPREFIX/Users/Shared/Riot Games/Riot Client.app" ]; }; then interactive read -r -p 'Region [euw]: ' region || exit 1 - exec ./scripts/installer.sh "${region:-euw}" + exec python3 scripts/nix-lifecycle.py ./scripts/installer.sh "${region:-euw}" fi -exec ./scripts/run.sh +exec python3 scripts/nix-lifecycle.py ./scripts/run.sh diff --git a/tests/test_nix_compilers.py b/tests/test_nix_compilers.py new file mode 100644 index 0000000..a7a900d --- /dev/null +++ b/tests/test_nix_compilers.py @@ -0,0 +1,52 @@ +"""Direct regression for FHS compiler wrapper installation and dispatch.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class CompilerTests(unittest.TestCase): + def test_build_hook_and_compiler_dispatch(self): + with tempfile.TemporaryDirectory(prefix="nix-compilers-") as tmp: + root = Path(tmp) + out = root / "out" + cwd = root / "build" + llvm = root / "llvm" + for directory in (out / "usr/bin", out / "usr/lib", cwd, llvm / "bin"): + directory.mkdir(parents=True) + for name in ("clang", "clang++"): + target = llvm / "bin" / name + target.write_text("#!/bin/sh\nprintf '%s\\n' \"$0\" \"$@\"\n") + target.chmod(0o755) + (out / "usr/bin" / name).symlink_to(target) + flake = (ROOT / "flake.nix").read_text() + hook = flake.split("extraBuildCommands = ''", 1)[1].split("\n '';", 1)[0] + for variable in ("llvmPackages.clang", "llvmPackages.clang-unwrapped"): + hook = hook.replace("${" + variable + "}", str(llvm)) + hook = hook.replace("${./scripts/nix-compiler.sh}", str(ROOT / "scripts/nix-compiler.sh")) + hook = hook.replace("''${", "${") + result = subprocess.run(["bash", "-eu", "-c", hook], cwd=cwd, + env={**os.environ, "out": str(out)}, + text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(list(cwd.iterdir()), [], "hook must operate on $out, not cwd") + for name, args, expected in ( + ("clang", ["-c", "source with spaces.c"], + ["-idirafter", "/usr/include", "-c", "source with spaces.c"]), + ("clang++", ["-target", "x86_64-apple-darwin", "source.cc"], + ["-target", "x86_64-apple-darwin", "source.cc"]), + ): + wrapper = out / "usr/bin" / name + self.assertFalse(wrapper.is_symlink() and wrapper.resolve().is_relative_to(llvm)) + script = wrapper.read_text().replace("/usr/bin/", str(out / "usr/bin") + "/") + wrapper.write_text(script) + result = subprocess.run([str(wrapper), *args], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines(), [str(llvm / "bin" / name), *expected]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nix_copy_manifest.py b/tests/test_nix_copy_manifest.py new file mode 100644 index 0000000..d87f69c --- /dev/null +++ b/tests/test_nix_copy_manifest.py @@ -0,0 +1,101 @@ +"""Managed refresh regressions; all installations are disposable fixtures.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +COPY = ROOT / 'scripts/nix-copy.py' +MANIFEST = '.nix-managed-files.json' + + +class ManagedCopyTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR')) + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.source = self.root / 'source' + self.destination = self.root / 'workspace' + self.source.mkdir() + self.destination.mkdir() + self.put(self.source, 'scripts/current.sh', 'current') + + def put(self, root, name, content='user data'): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + return path + + def refresh(self, success=True, source=None, destination=None): + result = subprocess.run([sys.executable, str(COPY), str(source or self.source), + str(destination or self.destination)], + capture_output=True, text=True) + if success: + self.assertEqual(result.returncode, 0, result.stderr) + else: + self.assertNotEqual(result.returncode, 0) + return result + + def manifest(self, files=(), directories=()): + path = self.destination / MANIFEST + path.write_text(json.dumps({'version': 1, 'files': list(files), + 'directories': list(directories)})) + return path + + def test_refresh_prunes_removed_files_and_empty_directories(self): + stale = self.put(self.source, 'scripts/obsolete/nested/old.sh') + self.put(self.source, 'NOTICE.md') + self.refresh() + stale.unlink() + stale.parent.rmdir() + stale.parent.parent.rmdir() + (self.source / 'NOTICE.md').unlink() + self.refresh() + self.assertFalse((self.destination / 'scripts/obsolete').exists()) + self.assertFalse((self.destination / 'NOTICE.md').exists()) + self.assertEqual((self.destination / 'scripts/current.sh').read_text(), 'current') + state = json.loads((self.destination / MANIFEST).read_text()) + self.assertEqual(state['files'], ['scripts/current.sh']) + self.assertEqual(state['directories'], ['scripts']) + + def test_removed_tree_retains_unmanaged_children_and_mutable_roots(self): + self.put(self.source, 'docs/old.txt') + self.refresh() + (self.source / 'docs/old.txt').unlink() + (self.source / 'docs').rmdir() + preserved = ['docs/personal.txt', 'scripts/local.sh', 'build/cache', + 'usr-local/bin/tool', 'prefix/game/save', 'other/data'] + for name in preserved: + self.put(self.destination, name) + self.refresh() + self.assertFalse((self.destination / 'docs/old.txt').exists()) + for name in preserved: + self.assertEqual((self.destination / name).read_text(), 'user data') + + def test_migration_preserves_unknown_legacy_files_then_tracks_new_files(self): + self.put(self.destination, 'scripts/legacy.sh') + self.refresh() + self.assertTrue((self.destination / MANIFEST).is_file()) + (self.source / 'scripts/current.sh').unlink() + self.refresh() + self.assertTrue((self.destination / 'scripts/legacy.sh').is_file()) + self.assertFalse((self.destination / 'scripts/current.sh').exists()) + + + + + + + + + + + + + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_nix_lifecycle.py b/tests/test_nix_lifecycle.py new file mode 100644 index 0000000..72ebd3e --- /dev/null +++ b/tests/test_nix_lifecycle.py @@ -0,0 +1,90 @@ +"""Inert fixtures: no real Darling execution or live prefix modification.""" +import importlib.util +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest +from unittest.mock import MagicMock + +ROOT = Path(__file__).resolve().parents[1] + + +class IdleTests(unittest.TestCase): + def test_unreadable_maps_fail_closed_but_disappearing_process_is_skipped(self): + spec = importlib.util.spec_from_file_location('nix_idle_fixture', ROOT / 'scripts/nix-idle.py') + assert spec is not None and spec.loader is not None + idle = importlib.util.module_from_spec(spec) + # Older versions run on import: point even those at an inert prefix. + with tempfile.TemporaryDirectory() as folder: + from unittest.mock import patch + with patch.dict(os.environ, DPREFIX=folder): + spec.loader.exec_module(idle) + process = MagicMock() + process.name = '123' + process.stat.return_value.st_uid = os.getuid() + (process / 'maps').read_text.side_effect = PermissionError('denied') + with self.assertRaisesRegex(RuntimeError, 'Cannot inspect'): + idle.client_active(Path('/fixture/prefix'), processes=[process]) + (process / 'maps').read_text.side_effect = FileNotFoundError() + self.assertFalse(idle.client_active(Path('/fixture/prefix'), processes=[process])) + + +class LifecycleTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + scripts = self.root / 'scripts' + scripts.mkdir() + for name in ('nix-session.sh', 'nix-lifecycle.py'): + if (ROOT / 'scripts' / name).exists(): + shutil.copy(ROOT / 'scripts' / name, scripts / name) + self.log = self.root / 'log' + self.env = dict(os.environ, DARLING_WORKSPACE=str(self.root), + DPREFIX=str(self.root / 'prefix'), TEST_LOG=str(self.log)) + (self.root / 'prefix/Applications/Riot Client.app').mkdir(parents=True) + (scripts / 'nix-state.py').write_text('raise SystemExit(0)\n') + (scripts / 'nix-idle.py').write_text('''import os +calls = 0 +def client_active(prefix, **kwargs): + global calls + calls += 1 + with open(os.environ['TEST_LOG'], 'a') as f: + f.write('scan\\n') + return calls < 2 +''') + (scripts / 'darling-rootless.sh').write_text('''#!/bin/bash +echo "shutdown:$*" >> "$TEST_LOG" +if [ "${SHUTDOWN_RC:-0}" != 0 ] && [ ! -e "$TEST_LOG.retried" ]; then + touch "$TEST_LOG.retried" + exit "$SHUTDOWN_RC" +fi +exit 0 +''') + client = scripts / 'run.sh' + client.write_text('''#!/usr/bin/env bash +echo client-finished >> "$TEST_LOG" +exit "${CLIENT_RC:-0}" +'''.replace('#!/usr/bin/env bash', '#!' + str(shutil.which('bash')))) + client.chmod(0o755) + + def run_session(self, **environment): + return subprocess.run(['bash', str(self.root / 'scripts/nix-session.sh')], + env=dict(self.env, **environment), text=True, + capture_output=True, timeout=10) + + def test_exit_waits_for_command_and_clients_before_shutdown(self): + result = self.run_session() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.log.read_text().splitlines(), + ['client-finished', 'scan', 'scan', 'shutdown:shutdown']) + + def test_command_failure_preserved_and_shutdown_failure_reported(self): + result = self.run_session(CLIENT_RC='17', SHUTDOWN_RC='23') + self.assertEqual(result.returncode, 17, result.stderr) + self.assertIn('shutdown failed', result.stderr) + self.assertIn('23', result.stderr) + self.assertEqual(self.log.read_text().splitlines().count('shutdown:shutdown'), 2) + self.assertIn('shutdown:shutdown', self.log.read_text()) diff --git a/tests/test_nix_repairs.py b/tests/test_nix_repairs.py index f0ee4f5..1a6e8c4 100644 --- a/tests/test_nix_repairs.py +++ b/tests/test_nix_repairs.py @@ -397,8 +397,11 @@ class SessionTests(unittest.TestCase): with tempfile.TemporaryDirectory(prefix='nix-dispatch-test-') as directory: root = Path(directory) (root / 'scripts').mkdir() - for name in ['nix-state.py', 'nix-session.sh']: + for name in ['nix-state.py', 'nix-session.sh', 'nix-lifecycle.py']: shutil.copy(ROOT / 'scripts' / name, root / 'scripts' / name) + # Keep lifecycle dispatch inert: no real /proc scan or Darling call. + (root / 'scripts/nix-idle.py').write_text('def client_active(prefix): return False\n') + (root / 'scripts/darling-rootless.sh').write_text('exit 0\n') (root / 'usr-local/bin').mkdir(parents=True) (root / 'usr-local/bin/darling').write_text('never executed') (root / 'usr-local/bin/darlingserver').write_text('never executed')