From 3c0b9cdfd3e7bc4c2a43bf5bc346c29496763146 Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Sun, 18 Oct 2026 00:21:30 +0200 Subject: [PATCH 1/4] feat(nix): add experimental rootless FHS package and desktop launcher --- .gitattributes | 3 + README.md | 29 +- darling-lol-launcher.sh | 60 ++++ darling-lol.png | Bin 0 -> 2526 bytes darling-lol.svg | 6 + docs/SETUP.md | 88 ++++- docs/TROUBLESHOOTING.md | 42 +-- flake.lock | 27 ++ flake.nix | 328 ++++++++++++++++++ patches/cctools-port.patch | 38 +++ patches/darling.patch | 8 +- scripts/build.sh | 6 +- scripts/darling-rootless.sh | 14 + scripts/fhs-entry.sh | 9 + scripts/install-runtime.sh | 15 +- scripts/installer.sh | 9 +- scripts/nix-copy.py | 44 +++ scripts/nix-idle.py | 36 ++ scripts/nix-session.sh | 32 ++ scripts/nix-state.py | 164 +++++++++ scripts/prepare.py | 36 +- scripts/run.sh | 5 + scripts/update.sh | 21 +- scripts/validate.py | 3 + tests/test_nix_launcher.py | 90 +++++ tests/test_nix_repairs.py | 519 +++++++++++++++++++++++++++++ tests/test_nix_runtime_identity.py | 111 ++++++ upstream/manifest.json | 6 +- 28 files changed, 1686 insertions(+), 63 deletions(-) create mode 100644 .gitattributes create mode 100644 darling-lol-launcher.sh create mode 100644 darling-lol.png create mode 100644 darling-lol.svg create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 patches/cctools-port.patch create mode 100644 scripts/darling-rootless.sh create mode 100644 scripts/fhs-entry.sh create mode 100644 scripts/nix-copy.py create mode 100644 scripts/nix-idle.py create mode 100755 scripts/nix-session.sh create mode 100644 scripts/nix-state.py create mode 100644 tests/test_nix_launcher.py create mode 100644 tests/test_nix_repairs.py create mode 100644 tests/test_nix_runtime_identity.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..00c1a6e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Unified-diff context uses a single space for an empty source line. +# Do not report that patch syntax as trailing source whitespace. +patches/*.patch whitespace=-blank-at-eol diff --git a/README.md b/README.md index ccf8377..d997fb1 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,34 @@ Game files, Riot installers, account databases, tokens, local prefixes, captures Use the dependency list in the [pinned Darling source](https://github.com/darlinghq/darling/tree/60ba801decee7a00782f74f6be4c8ffb013f79ff) for your distribution. `tools/env-check.sh` provides a starting check; its legacy package names may need adapting to your distro. -## Build on the other PC +## Experimental Nix/rootless package + +```sh +nix run github:lucannez64/darling-lol#installer +nix run github:lucannez64/darling-lol#default -- --update +nix run github:lucannez64/darling-lol#default +nix run github:lucannez64/darling-lol#shell +``` + +Or from a local checkout: + +```sh +nix run .#installer +nix run .#default +``` + +Nix supplies the launcher/dependencies, not a prebuilt Darling or game. First +setup is interactive and builds into the writable per-user workspace. The +desktop entry requests a terminal; EOF stdin produces an actionable error. +Run updates only after manually closing the clients and shutting Darling down +in the original namespace. A live prefix marker blocks a second launch rather +than killing or attempting to reconnect. One workspace/foreground namespace is +supported at a time. Readiness is per canonical prefix, configured guest and +compatible package/runtime/source identity. Store closure roots are retained +for mutable binaries; do not delete `build/nix-gcroots/` while using them. +See [Nix setup and operational limits](docs/SETUP.md#nixos-package). + +## Build on the other PC (non-Nix) ```sh git clone https://github.com/lucannez64/darling-lol.git diff --git a/darling-lol-launcher.sh b/darling-lol-launcher.sh new file mode 100644 index 0000000..604c9f0 --- /dev/null +++ b/darling-lol-launcher.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +WORKSPACE="${XDG_DATA_HOME:-$HOME/.local/share}/darling-lol" +SHARE_DIR="@SHARE_DIR@" +FHS_BIN="@FHS_BIN@" +UNSHARE_BIN="@UNSHARE_BIN@" +# The store package is both a compatibility identity and a closure GC root. +export DARLING_PACKAGE_ID="$SHARE_DIR" +export DISPLAY="${DISPLAY:-:0}" +export EGL_PLATFORM="${EGL_PLATFORM:-x11}" +export DPREFIX="${DPREFIX:-$HOME/.darling}" +export DARLING_HOST_UID="$(id -u)" +export DARLING_HOST_GID="$(id -g)" +export DARLING_WORKSPACE="$WORKSPACE" + +python3 - "$WORKSPACE" <<'PY' +import pathlib, sys +workspace = pathlib.Path(sys.argv[1]) +for path in [workspace, *workspace.parents]: + if path.is_symlink(): + raise SystemExit(f'Refusing symlink workspace ancestor: {path}') +if (workspace / '.launcher.lock').is_symlink(): + raise SystemExit('Refusing symlink workspace lock') +PY +mkdir -p "$WORKSPACE" +# One mutable base runtime per workspace; do not refresh scripts mid-session. +exec 9<>"$WORKSPACE/.launcher.lock" +flock -n 9 || { echo 'Another darling-lol workspace session is active; use its terminal.' >&2; exit 1; } +# Do not attempt to reconnect across separately created user namespaces. No kill, +# sudo, or shutdown is attempted here (the PID might even be stale/reused). +if [ -f "$DPREFIX/.init.pid" ]; then + read -r server_pid < "$DPREFIX/.init.pid" || true + if [[ "${server_pid:-}" =~ ^[1-9][0-9]*$ ]] && [ -d "/proc/$server_pid" ]; then + state=$(awk '/^State:/ {print $2}' "/proc/$server_pid/status") + if [ "$state" != Z ]; then + echo 'Prefix has a live server marker; namespace reconnect is unsupported.' >&2 + echo 'Use the original darling-lol-shell terminal to run darling shutdown, then retry. No processes were stopped.' >&2 + exit 1 + fi + fi +fi +python3 "$SHARE_DIR/scripts/nix-copy.py" "$SHARE_DIR" "$WORKSPACE" +mkdir -p "$WORKSPACE/usr-local" +cd "$WORKSPACE" +# Map subordinate IDs before bubblewrap sets no_new_privs. UID 0 is not host root. +fhs_command=("$UNSHARE_BIN" --user --map-auto --map-current-user --setuid=0 --setgid=0 "$FHS_BIN") +# Keep this supervisor alive to hold the lock: bubblewrap may close extra FDs. +# It never owns or signals game processes; foreground execution carries SIGINT. +case "${1:-}" in + --shell) shift; "${fhs_command[@]}" bash "$@";; + --update) shift; "${fhs_command[@]}" ./scripts/update.sh "$@";; + --installer) shift; "${fhs_command[@]}" bash ./scripts/nix-session.sh --installer "$@";; + *) + if [ $# -gt 0 ]; then + "${fhs_command[@]}" "$@" + else + "${fhs_command[@]}" bash ./scripts/nix-session.sh + fi;; +esac diff --git a/darling-lol.png b/darling-lol.png new file mode 100644 index 0000000000000000000000000000000000000000..8369dfc2fceba02251510572909468b568a61ada GIT binary patch literal 2526 zcmV<42_g20P)}=6G=+cyCE+D96auvtssptn z)Y?i%oOY}oYXwJVoQ}1{jxU3C+HpE|+D_>>T3ggs>T9(QptexUi;Pg9IUvx|gx4eo zBzKpWyUX44IJ*dg3f$i9vzKRczt2qm+kJK?`@Ls(pZz_%03n1BLI@#*5JHLtE9A!f z<7ZyS)0su!m<@@k02%<)0;mGuMR6Gd&?GRv(#k*QxH>07t zb=3_9LQV6S6wd-|uIjdn+%9mwI(oYGUs?vBzP`S^Zz%AD1Xcmys@f(0MKIu*-tvKm z(B7VaE&=e5pEiwW_y&N@QqdmCYOfS>MJ#gg2!=ZVfnei&1{mk3(RSM+IqBhoE$@kh z--Zpgy6Qlv@skYfO*;6)qE>LZDNxsR%dqqSf#CEO2KF}qm3DT3*u@69z><7>0+#a9h*2-n!>TxsXLdczf%sDuuAcSOWL98>p%ZXC}gUCEb2VZNPN%>SR zKWxhZs0lVLqv2gwXbFTGZ&4lq3Hfl|yF?0#OZjc9%UbFxYU&z4B02Wh+AC5}T=LmH z-L3nxE@!1N!R6gb_KFmY;KI@|vlM{y#T>*{BvvR1fKkRpl{~IAAyC)!VcP*PYxsyO zF^-$Timde+C0SdMn1*36;qT8XZBJ$d&L*>J?Q((cBBp;gG&We1i+ZIQFM5q5>?A? zH2;#VM-h8vGX{6InmvV!_*IMpl%mL$&6te3wGSZpt%Wb{EWl;d~!h7vlxCI43F;-rN(QEECZx`Kw0^oqsDH9M_X=~1A!B7Ak68QX5ci zVw?i1@4F4UqX0Ol;$zL`gCw0QjJBXUwXf&aXUE1yI_gXKgJ7;C~pWfykrlkZS9IN)ujd;2fpbO@wYJ09PX= zVlQuoD&7y(LpKzFEC#pjQ+6#-GETqODN_I}3>KL)f)pqTje%|`02UXou)q@fc5>7%D0`{^#gHB-FnzJI^cSwo4vhoK|t0WNp&zjr@Mr%r%M18?tzj*4b#m=&Jj zDAaxTOUe&^=4=$n@KNZ90$^Yal}lSt`@mQC| zp{t?^!1?Ln74QUW;hlVqIlNoGV5TF#_)87$Xw`ORa8U$6^_NF-E-U}Fy0UU7&|QFo z2HtuH$?ylz83n+>a`NcU{si4o02~tg)kX}R=!Whn01hx-K@RvQ2-P&p7@>l;m=*s^y9rzwiotzG@V+L^6i;FWiNAylYqjFl^TV#xK9v55B=y7Qju5zk%U0YymL3 zeR5ty!vg3W0R1MkjW809U1lX!1fUNNFfM?|dF|~x;E=e20I(<=U|jtg7eK{sFcHPp zH=cB=`Iv$LNJs8bj9VY$>i2NlUy({W(#dEomFz=X+h+5B(AVeK(D70G13?UJkXQ@{ z7`Hxue(fPAnkE34lx_BV00BJ<+DTTD@qM-fAWAY@vTus$ND91}ZJDeifrjd_jmN>D z--~X^)pSI=4m2nk2$CEdtSm<~BAKOQW+?zX!`9p0AyQBr>=)LSv!FK;-U~3tMk|O~ z4A|Dw-MU}tvj741n614c1tSDoYjs&m7l9X+tqwNs1TZhZ-6Dm+Ah-8+9lXJbr-2e& ztOA%j%3X^R%RsQN=CXz-mxYskXHJ(_)funNoS*Z)5ywf$r=r~lUv#t{px2-JCM5Rc zx*lStir)08u(u#9o57xSl9CXXMAC}|OjRga%O2N`C(n1|}V6_4;4gFL;F14?^ZyV@K%^eTG{|_MXMUZF)lhcd|LjM^|ssf-1p^dLnc0jVjVAyMvam6Cx o_Z;3fA%qY@2qAt<8 literal 0 HcmV?d00001 diff --git a/darling-lol.svg b/darling-lol.svg new file mode 100644 index 0000000..7ec69b9 --- /dev/null +++ b/darling-lol.svg @@ -0,0 +1,6 @@ + + darling-lol + + + + diff --git a/docs/SETUP.md b/docs/SETUP.md index 7c669a2..e0a7b18 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -1,5 +1,74 @@ # 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. + +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. + +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. + +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. + +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. + +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. + +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. + +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`. + + 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. Git LFS must be available before fetching the upstream submodules. The pinned `darling-swift` submodule's `.lfsconfig` points at the retired `git-lfs.darlinghq.org`, which no longer accepts connections; `prepare.py` redirects that submodule's LFS requests to GitHub, which still serves the objects. Without that override a fresh `git submodule update` blocks with no output right after `src/external/sqlite` is checked out. The local development checkout had removed prebuilt Swift files from its working directory; that deletion is deliberately not exported. A fresh upstream checkout keeps its original Swift assets and licensing. Do not install Git LFS pointer text as a library. @@ -12,22 +81,7 @@ python3 scripts/configure.py --guest-user my-linux-user python3 scripts/configure.py --sdk /usr/local/libexec/darling ``` -The runtime installation wrappers resolve `DPREFIX` first, otherwise the saved -`configure.py --prefix` setting. The configured guest account supplies the -default guest home. `RIOT_GUEST_HOME`, when set, is an absolute **host** path -inside the selected prefix, not a guest `/Users/...` path. Installers, runtime -validation and the Riot launcher's guest library paths use that selection. -Paths containing spaces are supported by isolated regression fixtures. Explicit -environment overrides win over saved configuration; clear stale values before -selecting a different installation. - -A complete real installation using a custom prefix has not been verified. -Archived tools may still assume the default layout. Runtime validation fails on -missing expected binaries, unreadable/missing directories or checker errors; -an empty input set is not a successful validation. - -Runtime installation requires the selected runtime to stop before overlay -replacement. Failed shutdown blocks replacement. +The normal wrappers currently support the default `~/.darling` prefix and `/usr/local` base installation. The configurator exposes a custom prefix for developers, but several archived tools assume the default layout. Custom prefixes/install locations have not been tested end to end. The build uses Unix Makefiles because the incremental runtime helpers read CMake `flags.make` and `link.txt`. Ninja is not a drop-in replacement for those helpers. @@ -39,7 +93,7 @@ Linux 5.14 or newer is required for `MADV_POPULATE_WRITE`, used when Darwin's al The runtime installer requires native `pkg-config` entries for `x11`, `x11-xcb`, `xcb-shm` and `xext`, plus the base Darling SDK. -Run configuration, builds and the per-user runtime installation as your ordinary account. Use sudo only for the base CMake installation and the incremental server installer. Running the whole build or per-user install under sudo can create root-owned files and configure the wrong guest home. `scripts/install-runtime.sh` initializes the selected user prefix and creates its `private/tmp` directory with mode `1777`, so the installer can write guest temporary files. It then shuts down Darling before replacing the paired runtime: first exit the game and both clients manually. +Run configuration, builds and the per-user runtime installation as your ordinary account. Use sudo only for the base CMake installation and the incremental server installer. Running the whole build or per-user install under sudo can create root-owned files and configure the wrong guest home. `scripts/install-runtime.sh` initializes the default user prefix and creates `~/.darling/private/tmp` with mode `1777`, so the installer can write guest temporary files. It then shuts down Darling before replacing the paired runtime: first exit the game and both clients manually. The normal launcher starts neither an Electron inspector nor client API/UI helpers. It clears inherited `DYLD_INSERT_LIBRARIES`, retains Darling runtime library/framework overrides, supplies an absolute log directory to the launcher, and selects the League product. The log-directory/Chromium argument shim, empty-autofill hook and optional native VPX bridge are no longer injected or built by runtime installation. Existing copies on disk are inactive and may remain. Child-process log-directory rewriting and Chromium feature overrides previously supplied by the shim are removed; client startup without those workarounds needs manual validation. The first-time installer uses `--skip-to-install` and omits the installed client's `--app-root`, avoiding an immediate exit or inaccessible intro screen. The project ships no inspector hooks, client API helpers, input automation or client binary patches. Keep account data, logs and captures private. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 954d6e7..6c24424 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -1,5 +1,23 @@ # Troubleshooting +## NixOS launcher fails before Riot opens + +A message saying Darling must be setuid root is not fixed by running `sudo` +inside `buildFHSEnv`: bubblewrap enables `no_new_privs`, so setuid elevation is +ignored there. Use the package's rootless launcher with subordinate UID/GID +mapping enabled. Never make a mutable workspace executable host-setuid-root. + +If a newly installed client aborts with `Cannot load libavformat.so.62 (ELF)`, +check the FHS runtime dependencies. The generated Darling wrappers use FFmpeg 8 +SONAMEs, so an unversioned nixpkgs `ffmpeg` dependency can break them when its +major changes. The package pins `ffmpeg_8`; do not create fake ABI symlinks. + +Older packages used `cp -n` and never refreshed already-copied tools. The current +launcher replaces packaged source files while retaining compiled/runtime data. +An incomplete prefix can be backed up by renaming it with Darling stopped; +never delete a populated Riot/game prefix just to repair the launcher. + + Use the [setup instructions](SETUP.md) from the repository root as your ordinary user. Exit the game and both clients manually before a full installation or Darling restart. Incremental server/memory installers can replace files without interrupting processes, but their changes need a later full restart to activate. ## Memory installer: permission denied @@ -56,18 +74,6 @@ The installer fetcher no longer treats a missing archive `Info.plist` or the inf There is also a separate known compatibility gap: native LaunchAgent watch registration can return HTTP 409 after the League install path is accepted, leaving the download idle. A historical inspector hook handles this and interrupted `pending_verify` states in 28 mock cases, but that hook is disabled in normal launches and excluded from the manual workflow. There is no verified runtime-only replacement for that handoff in this snapshot. Do not assume the native permission and launcher fixes resolve every idle Install case, or enable debugger/API automation to work around it. -## `Unable to read system root certificates` or `Error -25263` - -Riot logs `CertBundle: Unable to read system root certificates` and Chromium logs `[known_roots_mac.cc(60)] Unable to determine trusted roots; assuming all roots are trusted! Error -25263` when the prefix has no system trust domain. Security reads that domain from `/System/Library/Keychains/SystemRootCertificates.keychain` and `SystemTrustSettings.plist`, which Darling does not ship, so `SecTrustSettingsCopyCertificates` returns `errSecNoTrustSettings`. - -`tools/ensure-darling-root-certificates.py` builds both from the host CA bundle (`/etc/ssl/cert.pem` or whichever bundle Python's `ssl` reports; `--bundle` overrides it). The launcher and `scripts/install-runtime.sh` run it. Each self-signed root gets an unrestricted trust entry. The keychain is built with the guest `security` tool in a single `darling shell`. A stamp in the same directory records the bundle's digest, so later launches skip it without starting Darling until the host bundle changes. If Darling was already running and the guest could not write the directory, the script installs the files on the host and says they apply after `darling shutdown`. A failure prints `Darling system root certificates were not installed: …` and the launch continues. To rebuild by hand: - -```sh -python3 build/runtime/tools/ensure-darling-root-certificates.py --force -``` - -`python3 -m unittest tests.test_root_certificates` compares the generated SHA-1 keys, issuer names and serial numbers with OpenSSL, and checks that an unchanged bundle is skipped. It uses stub `darling` and `security` commands. Reading the keychain and the system trust domain inside a real guest (`security find-certificate -a /System/Library/Keychains/SystemRootCertificates.keychain`, `security dump-trust-settings -s`) is not yet confirmed. - ## Riot closes after a network lookup is cancelled Two local crashes were traced to `CFNetwork::_HostCancel` calling `CFRetain(NULL)`. @@ -246,18 +252,6 @@ A non-zero error or `FAILED` means the frames cannot reach the window. Before 20 An additional NVIDIA report on `68f816d` showed successful optimal-tiling import but repeated `Metal Device timed out giving us a Drawable to present` messages. Its [presentation trace](https://github.com/lucannez64/darling-lol/issues/3#issuecomment-6214732591) shows all three drawables queued and all three main-thread presentation callbacks running, followed by timeouts with three frames still queued. The compositor only prepared a Metal layer when it was the root layer, skipping nested Metal layers. It now prepares Metal layers throughout the tree. A standalone nested-layer reproduction exhausts the pool before this correction and passes ten presentations with pixel readback after it; the root-layer probe also passes. Confirmation on the reporter's NVIDIA system is still needed. -### What Metal reports about the GPU - -`MTLDevice.name` is the selected Vulkan device's name in macOS style: the trailing driver note in brackets and the `(R)`/`(TM)` marks are removed (`AMD Radeon RX 6800 XT (RADV NAVI21)` becomes `AMD Radeon RX 6800 XT`, `Intel(R) UHD Graphics 630 (CML GT2)` becomes `Intel UHD Graphics 630`, `llvmpipe (LLVM …)` becomes `llvmpipe`). NVIDIA names are unchanged. The name is never replaced with another vendor's GPU. Before 2026-10-17 it was always `Darling Indium Device`. - -`recommendedMaxWorkingSetSize` is the size of the device-local Vulkan heaps (the VRAM) on a discrete GPU. On integrated and software GPUs it is the size of all heaps, because their device-local heap can be just the firmware carve-out while they also use system RAM; macOS likewise reports a large share of system RAM for unified-memory GPUs. It falls back to the old 1 GiB when Vulkan reports no usable heap. `hasUnifiedMemory` stays `YES` by default: with `NO`, apps switch to managed and private resource paths (`synchronizeResource:`, private buffers created with data) that are not implemented here. Set these in the game's environment to override: - -- `DARLING_METAL_GPU_NAME=`: report this name. -- `DARLING_METAL_VRAM_MB=`: report this working-set size in MiB. Other values are ignored. -- `DARLING_METAL_HOST_UNIFIED_MEMORY=1`: report `hasUnifiedMemory` from the GPU type (`YES` for integrated and software GPUs, `NO` for discrete ones). This is experimental; on a discrete GPU it can break rendering. - -Each process reads these once. - ### Host-side checks These are host settings, not game settings. Run on AC power, and use a performance power profile (`powerprofilesctl set performance`, or `asusctl profile -P Performance` on ASUS laptops). On the tested desktop the game is limited mostly by its own main thread on the CPU, not by the GPU (see [status](STATUS.md#game-fps-2026-10-1314)), so laptop CPU power limits matter as much as the GPU choice. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..6197b4b --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1791956154, + "narHash": "sha256-jBYGPi4udQ9uo4KQp9m2J0546nJVQitG15gvZSaVVAs=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "55bfb4d7437bd4e09ab7cba58a43bd68eb310044", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4fe1a74 --- /dev/null +++ b/flake.nix @@ -0,0 +1,328 @@ +{ + description = "Darling League of Legends - experimental macOS LoL runner on Linux via Darling"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + + # Python environment with dependencies required by tooling + pythonEnv = pkgs.python3.withPackages ( + ps: with ps; [ + requests + urllib3 + websocket-client + pillow + ] + ); + + darlingEntry = pkgs.writeShellScript "darling-lol-entry" (builtins.readFile ./scripts/fhs-entry.sh); + darlingRunner = pkgs.runCommand "darling-lol-rootless-runner" { } '' + mkdir -p $out/bin + cp ${./scripts/darling-rootless.sh} $out/bin/darling + chmod +x $out/bin/darling + patchShebangs $out/bin/darling + ''; + + # Common runtime libraries needed by Darling, Vulkan, X11, Audio, and graphics + buildDeps = with pkgs; [ + # Build tools & compilers + cmake + gnumake + ninja + pkg-config + clang + lld + llvm + llvm.dev + llvmPackages.clang-unwrapped + bison + flex + git + git-lfs + nix + curl + unzip + file + patchelf + util-linux + + # System & core libraries + glibc + glibc.dev + glibc.static + zlib + zlib.dev + libffi + libffi.dev + libbsd + libbsd.dev + libmd + libmd.dev + libcap + libelf + fuse + systemdLibs # libudev + dbus + libxml2 + openssl + libedit + + # Graphics & Display + libGL + libGLU + mesa + vulkan-headers + vulkan-loader + vulkan-tools + cairo + freetype + fontconfig + libpng + libpng.dev + libjpeg + libjpeg.dev + libtiff + giflib + # Darling's generated AV wrappers bind FFmpeg 8 SONAMEs (e.g. .62). + # Do not let nixpkgs' default FFmpeg major silently break installed builds. + ffmpeg_8 + + # X11 & input + xorgproto + libx11 + libxext + libxrandr + libxcursor + libxinerama + libxcomposite + libxdamage + libxfixes + libxi + libxrender + libxtst + libxcb + xcbutil + xcbutilwm + xcbutilimage + xcbutilkeysyms + xcbutilrenderutil + libxkbfile + libxkbcommon + + # Audio + pulseaudio + libpulseaudio + alsa-lib + + # Python + pythonEnv + ]; + + # FHS environment for building and running Darling and League of Legends. + # Darling requires root/multitasking mount & namespace semantics, /usr/local layout, + # and dynamically compiled overlays. An FHS environment provides the standard standard + # filesystem hierarchies (/usr/include, /usr/lib, /usr/bin) and native dynamic linker. + darlingLolFhs = pkgs.buildFHSEnv { + name = "darling-lol-fhs"; + targetPkgs = pkgs: buildDeps; + multiPkgs = pkgs: [ pkgs.glibc ]; + + extraOutputsToInstall = [ "dev" "out" ]; + + extraBwrapArgs = [ + # The outer user namespace maps subordinate IDs before no_new_privs. + # These capabilities apply only inside that namespace, not on the host. + "--cap-add" "ALL" + "--bind-try" (''$'' + ''{DARLING_WORKSPACE}/usr-local'') "/usr-local" + ]; + + 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++ + ''; + + profile = '' + export DPREFIX="''${DPREFIX:-$HOME/.darling}" + export LC_ALL=C.UTF-8 + export LANG=C.UTF-8 + export DARLING_RUNNER_DIR=${darlingRunner}/bin + export PATH="$DARLING_RUNNER_DIR:/usr/local/bin:''${PATH:-}" + export PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:/usr/local/share/pkgconfig:/usr/lib/pkgconfig:/usr/lib64/pkgconfig:/usr/share/pkgconfig:''${PKG_CONFIG_PATH:-}" + export CMAKE_PREFIX_PATH="/usr/local:/usr:/usr/lib/cmake/llvm:''${CMAKE_PREFIX_PATH:-}" + unset NIX_LDFLAGS NIX_CFLAGS_COMPILE NIX_HARDENING_ENABLE CPATH LIBRARY_PATH + ''; + + runScript = pkgs.writeShellScript "darling-lol-run" '' + if [ $# -eq 0 ]; then + exec ${darlingEntry} bash + else + exec ${darlingEntry} "$@" + fi + ''; + }; + + # Wrapper script package to run scripts directly + darlingLol = pkgs.stdenv.mkDerivation { + pname = "darling-lol"; + version = "0.1.0"; + src = pkgs.lib.cleanSourceWith { + src = ./.; + filter = path: type: + let name = baseNameOf path; in + !(builtins.elem name [ ".git" "build" "__pycache__" "usr-local" "darling-install" ]) + && !(pkgs.lib.hasPrefix "result" name); + }; + + nativeBuildInputs = [ + pkgs.makeWrapper + ]; + + installPhase = '' + runHook preInstall + + mkdir -p $out/bin $out/share/darling-lol $out/share/icons/hicolor/scalable/apps $out/share/icons/hicolor/128x128/apps $out/share/pixmaps $out/share/applications + cp -r * $out/share/darling-lol/ + cp darling-lol.svg $out/share/icons/hicolor/scalable/apps/darling-lol.svg + cp darling-lol.png $out/share/icons/hicolor/128x128/apps/darling-lol.png + cp darling-lol.png $out/share/pixmaps/darling-lol.png + cp darling-lol.svg $out/share/pixmaps/darling-lol.svg + + # Install configured launcher + substitute ./darling-lol-launcher.sh $out/bin/darling-lol \ + --replace-fail "@SHARE_DIR@" "$out/share/darling-lol" \ + --replace-fail "@FHS_BIN@" "${darlingLolFhs}/bin/darling-lol-fhs" \ + --replace-fail "@UNSHARE_BIN@" "${pkgs.util-linux}/bin/unshare" + chmod +x $out/bin/darling-lol + wrapProgram $out/bin/darling-lol --prefix PATH : ${pkgs.lib.makeBinPath [ pythonEnv pkgs.coreutils pkgs.util-linux pkgs.gawk ]} + + makeWrapper $out/bin/darling-lol $out/bin/darling-lol-installer \ + --add-flags "--installer" + + makeWrapper $out/bin/darling-lol $out/bin/darling-lol-shell \ + --add-flags "--shell" + + makeWrapper $out/bin/darling-lol $out/bin/darling-lol-build \ + --add-flags "./scripts/build.sh" + + cat << 'DESKTOP_EOF' > $out/share/applications/darling-lol.desktop +[Desktop Entry] +Categories=Game; +Comment=Play League of Legends macOS client on Linux via Darling +Exec=darling-lol +GenericName=MOBA Game +Icon=darling-lol +Keywords=League;LoL;Riot;Darling; +Name=League of Legends +StartupWMClass=RiotClientServices +Terminal=true +Type=Application +Version=1.5 +DESKTOP_EOF + + runHook postInstall + ''; + + meta = with pkgs.lib; { + description = "Tooling and patches to run macOS League of Legends on Linux with Darling"; + homepage = "https://github.com/lucannez64/darling-lol"; + license = licenses.gpl3Only; + platforms = [ "x86_64-linux" ]; + }; + }; + in + { + checks.${system}.packaging = pkgs.runCommand "darling-lol-packaging-tests" { + nativeBuildInputs = [ pythonEnv pkgs.bash pkgs.git pkgs.coreutils pkgs.util-linux pkgs.gawk ]; + } '' + cp -R ${darlingLol}/share/darling-lol source + chmod -R u+w source + cd source + export HOME="$TMPDIR/home" PYTHONDONTWRITEBYTECODE=1 + mkdir -p "$HOME" + # Tests use private stub binaries/repositories, never Darling itself. + python3 -m unittest discover -s tests -v + python3 scripts/validate.py + grep -q '^Terminal=true$' ${darlingLol}/share/applications/darling-lol.desktop + touch "$out" + ''; + + packages.${system} = { + darling-lol = darlingLol; + darling-lol-fhs = darlingLolFhs; + default = darlingLol; + }; + + apps.${system} = { + default = { + type = "app"; + program = "${darlingLol}/bin/darling-lol"; + meta.description = "Launch League of Legends via Darling"; + }; + shell = { + type = "app"; + program = "${darlingLol}/bin/darling-lol-shell"; + meta.description = "Open an interactive shell inside the Darling FHS environment"; + }; + installer = { + type = "app"; + program = "${darlingLol}/bin/darling-lol-installer"; + meta.description = "Run the Darling and League of Legends rootless installer"; + }; + build = { + type = "app"; + program = "${darlingLol}/bin/darling-lol-build"; + meta.description = "Build the Darling runtime tree inside the FHS sandbox"; + }; + }; + + devShells.${system}.default = pkgs.mkShell { + packages = buildDeps ++ [ darlingLolFhs ]; + shellHook = '' + echo "Darling LoL build & development environment loaded." + echo "Tip: Run 'darling-lol-fhs' to enter the FHS build sandbox if standard paths (/usr/include, /usr/lib) are needed." + ''; + }; + }; +} diff --git a/patches/cctools-port.patch b/patches/cctools-port.patch new file mode 100644 index 0000000..eacef6a --- /dev/null +++ b/patches/cctools-port.patch @@ -0,0 +1,38 @@ +diff --git a/cctools/ld64/src/3rd/strlcpy.h b/cctools/ld64/src/3rd/strlcpy.h +--- a/cctools/ld64/src/3rd/strlcpy.h ++++ b/cctools/ld64/src/3rd/strlcpy.h +@@ -7,8 +7,15 @@ + extern "C" { + #endif + #include ++#include + ++#if defined(__GLIBC__) ++# if !__GLIBC_PREREQ(2, 38) + size_t strlcpy(char *dst, const char *src, size_t siz); ++# endif ++#else ++size_t strlcpy(char *dst, const char *src, size_t siz); ++#endif + + #ifdef __cplusplus + } +diff --git a/cctools/ld64/src/3rd/strlcat.h b/cctools/ld64/src/3rd/strlcat.h +--- a/cctools/ld64/src/3rd/strlcat.h ++++ b/cctools/ld64/src/3rd/strlcat.h +@@ -7,8 +7,15 @@ + extern "C" { + #endif + #include ++#include + ++#if defined(__GLIBC__) ++# if !__GLIBC_PREREQ(2, 38) + size_t strlcat(char *dst, const char *src, size_t siz); ++# endif ++#else ++size_t strlcat(char *dst, const char *src, size_t siz); ++#endif + + #ifdef __cplusplus + } diff --git a/patches/darling.patch b/patches/darling.patch index 00f4482..b399000 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -10588,13 +10588,19 @@ diff --git a/tools/shutdown-user.sh b/tools/shutdown-user.sh index 072a6e2..c804100 100755 --- a/tools/shutdown-user.sh +++ b/tools/shutdown-user.sh -@@ -1,26 +1,34 @@ +@@ -1,26 +1,40 @@ #!/bin/sh -if [ "$(whoami)" != "root" ] -then - echo "$0 must be run as root, invoking sudo" - exec sudo su -c "$0" "$@" ++# Rootless installs own only this workspace/prefix, never all launchd users. ++if [ -n "${DARLING_WORKSPACE:-}" ]; then ++ : "${DPREFIX:?rootless install requires an explicit prefix}" ++ exec python3 "$DARLING_WORKSPACE/scripts/nix-idle.py" ++fi ++ +if [ "$(whoami)" != "root" ]; then + echo "$0 must be run as root, invoking sudo" + exec sudo sh "$0" "$@" diff --git a/scripts/build.sh b/scripts/build.sh index 6113553..6bb9a13 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -20,4 +20,8 @@ cmake -S build/darling-src -B build/darling-src/build -G 'Unix Makefiles' \ -DCMAKE_C_FLAGS="-O3 -march=native -mtune=generic -pipe" \ -DCMAKE_CXX_FLAGS="-O3 -march=native -mtune=generic -pipe" cmake --build build/darling-src/build --parallel "${JOBS:-$(nproc)}" -echo 'Build complete. Install the base runtime with: sudo cmake --install build/darling-src/build' +if [ -n "${DARLING_WORKSPACE:-}" ]; then + echo 'Build complete. Install the base runtime with: cmake --install build/darling-src/build' +else + echo 'Build complete. Install the base runtime with: sudo cmake --install build/darling-src/build' +fi diff --git a/scripts/darling-rootless.sh b/scripts/darling-rootless.sh new file mode 100644 index 0000000..6414a70 --- /dev/null +++ b/scripts/darling-rootless.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${DARLING_HOST_UID:?missing Darling user namespace}" +: "${DARLING_WORKSPACE:?missing Darling workspace}" +if [ "${DARLING_SETUP:-0}" != 1 ] && [ "${1:-}" != shutdown ]; then + python3 "$DARLING_WORKSPACE/scripts/nix-state.py" check || { + echo 'Rootless runtime readiness missing; run darling-lol --installer in a terminal.' >&2 + exit 1 + } +fi +# This is namespace root (a subordinate host UID), not host root. Keep real +# UID/GID intact so Darling initializes the correct guest account and ownership. +exec setpriv --euid 0 --egid 0 --keep-groups \ + "$DARLING_WORKSPACE/usr-local/bin/darling" "$@" diff --git a/scripts/fhs-entry.sh b/scripts/fhs-entry.sh new file mode 100644 index 0000000..c6950ba --- /dev/null +++ b/scripts/fhs-entry.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${DARLING_HOST_UID:?start this environment with darling-lol}" +: "${DARLING_HOST_GID:?start this environment with darling-lol}" +# Carry only namespace-scoped capabilities into the ordinary-user process. +# Darling's shim temporarily switches effective IDs; real IDs stay the user's. +exec setpriv --ruid "$DARLING_HOST_UID" --euid "$DARLING_HOST_UID" \ + --rgid "$DARLING_HOST_GID" --egid "$DARLING_HOST_GID" --keep-groups \ + --inh-caps +all --ambient-caps +all "$@" diff --git a/scripts/install-runtime.sh b/scripts/install-runtime.sh index 6d3c345..677b2ab 100755 --- a/scripts/install-runtime.sh +++ b/scripts/install-runtime.sh @@ -3,6 +3,13 @@ set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" +if [ -n "${DARLING_WORKSPACE:-}" ]; then + python3 scripts/nix-idle.py + python3 scripts/nix-state.py check-base || { echo 'Base runtime readiness missing; run darling-lol --update.' >&2; exit 1; } + python3 scripts/nix-state.py invalidate + # Prefix initialization is a setup-only exception to the runner launch gate. + export DARLING_SETUP=1 +fi python3 scripts/configure.py . build/runtime/tools/runtime-paths.sh if [ -d build/venv/bin ]; then PATH="$repo/build/venv/bin:$PATH"; export PATH; fi @@ -16,7 +23,7 @@ prefix=$prefix_dir mkdir -p "$prefix/private/tmp" chmod 1777 "$prefix/private/tmp" # Build the system root store now that the prefix exists; Darling is stopped next anyway. -python3 build/runtime/tools/ensure-darling-root-certificates.py || +python3 build/runtime/tools/ensure-darling-root-certificates.py || \ echo 'Darling system root store not installed; the launcher retries it' >&2 python3 build/runtime/tools/stop-darling-runtime.py sh build/runtime/tools/install-riot-presentation-runtime.sh @@ -29,3 +36,9 @@ fi python3 build/runtime/tools/install-darling-system-profiler.py # Every guest process loads these overlays; refuse a set the compiler broke (check-darwin-tlv-calls.py). python3 build/runtime/tools/validate-riot-runtime.py +# Mark this canonical prefix and configured guest ready only after validation. +if [ -n "${DARLING_WORKSPACE:-}" ]; then + # Overlay installation can also refresh darlingserver; record the final base. + python3 scripts/nix-state.py stamp-base + python3 scripts/nix-state.py stamp +fi diff --git a/scripts/installer.sh b/scripts/installer.sh index 62c7ed6..48f607c 100755 --- a/scripts/installer.sh +++ b/scripts/installer.sh @@ -2,10 +2,13 @@ set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" +if [ -n "${DARLING_WORKSPACE:-}" ] && ! python3 scripts/nix-state.py check; then + echo 'Rootless runtime readiness is missing or incompatible; run darling-lol --installer in a terminal.' >&2 + exit 1 +fi +export PATH="${DARLING_RUNNER_DIR:+$DARLING_RUNNER_DIR:}$repo/usr-local/bin:/usr/local/bin:$PATH" region=${1:-euw} -# Riot publishes LAN, LAS and OCE installers under their platform codes. -case "$region" in lan) region=la1;; las) region=la2;; oce) region=oc1;; esac -case "$region" in euw|eune|na|br|kr|jp|la1|la2|oc1|ru|tr) ;; *) echo 'Use a Riot installer region code' >&2; exit 1;; esac +case "$region" in euw|eune|na|br|kr|jp|lan|las|oce|ru|tr) ;; *) echo 'Use a Riot installer region code' >&2; exit 1;; esac python3 scripts/configure.py >/dev/null bash build/runtime/tools/fetch-installer.sh "$region" build/installer unzip -q -o "build/installer/live.$region.zip" -d build/installer diff --git a/scripts/nix-copy.py b/scripts/nix-copy.py new file mode 100644 index 0000000..17874cb --- /dev/null +++ b/scripts/nix-copy.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Refresh managed package files without following mutable destination symlinks.""" +import os +from pathlib import Path +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) + else: + target.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix='.package-', dir=target.parent) + 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) diff --git a/scripts/nix-idle.py b/scripts/nix-idle.py new file mode 100644 index 0000000..8ce0dc0 --- /dev/null +++ b/scripts/nix-idle.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Read-only, fail-closed rootless maintenance preflight. Never signal a process.""" +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(): + 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 diff --git a/scripts/nix-session.sh b/scripts/nix-session.sh new file mode 100755 index 0000000..9626465 --- /dev/null +++ b/scripts/nix-session.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Run setup and the client in the SAME namespace. Never reconnect by killing it. +set -euo pipefail +cd "${DARLING_WORKSPACE:?start with darling-lol}" +ready() { python3 scripts/nix-state.py check; } +interactive() { + if [ ! -t 0 ]; then + echo 'Setup needs an interactive terminal. Run darling-lol --installer in a terminal (or use the desktop entry with Terminal=true).' >&2 + exit 1 + fi +} +if ! ready; then + interactive + echo "Prefix: $DPREFIX" + if ! python3 scripts/nix-state.py check-base; then + echo 'Base runtime missing or incompatible; building requires about 40–50 GB.' + read -r -p 'Build/update the rootless runtime? [y/N] ' reply || exit 1 + case "$reply" in y|Y) ;; *) exit 1;; esac + ./scripts/update.sh + else + ./scripts/install-runtime.sh + fi +fi +# Ready existing installations never rebuild merely because the launcher opens. +if [ "${1:-}" = --installer ] || + { [ ! -e "$DPREFIX/Applications/Riot Client.app" ] && + [ ! -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}" +fi +exec ./scripts/run.sh diff --git a/scripts/nix-state.py b/scripts/nix-state.py new file mode 100644 index 0000000..e52dff4 --- /dev/null +++ b/scripts/nix-state.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Per-prefix rootless readiness; no Darling execution or prefix writes.""" +import hashlib +import json +import os +from pathlib import Path +import pwd +import sys + +sys.dont_write_bytecode = True +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools')) +from runtime_paths import runtime_paths + + +def selected_paths(): + config_path = ROOT / 'build/config.json' + config = json.loads(config_path.read_text()) if config_path.exists() else {} + guest = config.get('guest_user') or pwd.getpwuid(os.getuid()).pw_name + config.setdefault('prefix', str(Path.home() / '.darling')) + config.setdefault('guest_home', '/Users/' + guest) + configured_prefix = Path(os.environ.get('DPREFIX') or config['prefix']).resolve() + env_prefix = os.environ.get('DPREFIX') + try: + os.environ['DPREFIX'] = str(configured_prefix) + prefix, home = runtime_paths(config) + finally: + if env_prefix is None: + os.environ.pop('DPREFIX', None) + else: + os.environ['DPREFIX'] = env_prefix + return prefix.resolve(), home.resolve(), guest + + +def guest_user(): + return selected_paths()[2] + + +def digest(paths): + result = hashlib.sha256() + for path in sorted(paths): + if path.is_file(): + result.update(str(path.relative_to(ROOT)).encode() + b'\0' + path.read_bytes()) + return result.hexdigest() + + +def base_identity(): + runtime_tree = ROOT / 'usr-local/libexec/darling' + runtime_modes = {name: (ROOT / 'usr-local/bin' / name).stat().st_mode & 0o777 + if (ROOT / 'usr-local/bin' / name).exists() else None + for name in ('darling', 'darlingserver')} + return {'schema': 2, 'package': os.environ.get('DARLING_PACKAGE_ID', ''), + 'runtime_modes': runtime_modes, + 'runtime_tree': inventory(runtime_tree), + 'sources': digest([* (ROOT / 'patches').glob('*.patch'), + ROOT / 'upstream/manifest.json', + * (ROOT / 'scripts').glob('*.py'), + * (ROOT / 'scripts').glob('*.sh'), + * (ROOT / 'tools').glob('*')]), + 'runtime': digest([ROOT / 'usr-local/bin/darling', + ROOT / 'usr-local/bin/darlingserver'])} + + +def inventory(root): + """Include all required trees and modes without hashing large installed binaries.""" + if not root.is_dir(): + return None + result = hashlib.sha256() + def enumeration_error(error): + raise error + + for directory, dirs, files in os.walk(root, followlinks=False, + onerror=enumeration_error): + for name in sorted(dirs + files): + path = Path(directory) / name + if any(token in path.name for token in ('.before-', '.rt-', '.llvm')): + continue + info = path.lstat() + target_str = '' + if path.is_symlink(): + try: + target_str = f':{os.readlink(path)}' + except OSError: + pass + result.update(str(path.relative_to(root)).encode() + b'\0') + result.update(f'{info.st_mode}:{info.st_size}:{info.st_mtime_ns}{target_str}'.encode()) + return result.hexdigest() + + +def overlay_identity(home): + result: dict[str, object] = {name: inventory(home / name) for name in + ('RuntimeLibraries', 'RuntimeFrameworks', + 'Library/Audio/Plug-Ins/Components/DarlingRiotAudio.component')} + helper = home / 'libDarlingXShm.dylib' + result['xshm'] = ([helper.stat().st_mode, helper.stat().st_size, + helper.stat().st_mtime_ns] if helper.is_file() else None) + return result + + +def identity(): + prefix, home, guest = selected_paths() + return dict(base_identity(), prefix=str(prefix), guest=guest, guest_home=str(home), + overlays=overlay_identity(home), uid=os.getuid(), gid=os.getgid()) + + +def read(path): + try: + return json.loads(path.read_text()) + except (OSError, ValueError): + return None + + +def atomic(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix('.tmp') + temporary.write_text(json.dumps(value, sort_keys=True) + '\n') + temporary.replace(path) + + +def main(): + action = sys.argv[1] + state = identity() + key = hashlib.sha256(state['prefix'].encode()).hexdigest() + marker = ROOT / 'build/nix-ready' / (key + '.json') + base_marker = ROOT / 'build/nix-base.json' + if action == 'guest': + print(state['guest']) + elif action == 'check-base': + return 0 if read(base_marker) == base_identity() else 1 + elif action == 'check': + return 0 if read(marker) == state and read(base_marker) == base_identity() else 1 + elif action == 'stamp-base': + if (not all((ROOT / 'usr-local/bin' / name).is_file() and + os.access(ROOT / 'usr-local/bin' / name, os.X_OK) + for name in ('darling', 'darlingserver')) or + not (ROOT / 'usr-local/libexec/darling').is_dir() or not state['package']): + raise SystemExit('Cannot stamp readiness without executable base runtime, SDK and package identity') + atomic(base_marker, base_identity()) + elif action == 'stamp': + if read(base_marker) != base_identity(): + raise SystemExit('Base runtime readiness is missing; run darling-lol --update in a terminal.') + home = Path(str(state['guest_home'])) + if not home.is_dir() or not any((home / 'RuntimeLibraries').glob('*')) or not any((home / 'RuntimeFrameworks').glob('*')): + raise SystemExit('Cannot stamp an empty or missing prefix runtime') + # Reuse the complete installation gate; readiness must never mint an + # apparently valid manifest for partial overlays, even when called directly. + import importlib.util + spec = importlib.util.spec_from_file_location('runtime_validation', ROOT / 'tools/validate-riot-runtime.py') + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.inputs(Path(str(state['prefix'])), home) + atomic(marker, state) + elif action in ('invalidate', 'invalidate-base'): + marker.unlink(missing_ok=True) + if action == 'invalidate-base': + base_marker.unlink(missing_ok=True) + else: + raise SystemExit(f'Unknown state action: {action}') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/prepare.py b/scripts/prepare.py index 0e8bc72..dca0a83 100755 --- a/scripts/prepare.py +++ b/scripts/prepare.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 """Fetch pinned Darling sources and apply each patch to its owning repository.""" -import argparse,json,os,subprocess,tempfile +import argparse,json,os,subprocess,tempfile,sys from pathlib import Path +# --check-only must not create import caches in the source package either. +sys.dont_write_bytecode=True from common import ROOT,render p=argparse.ArgumentParser(description=__doc__) p.add_argument('--source',type=Path,default=ROOT/'build/darling-src') @@ -25,19 +27,27 @@ def previous_patches(component): if shown.returncode==0:yield render(shown.stdout) if not (source/'.git').exists(): if a.check_only:p.error('check-only requires an existing pinned source checkout') + if source.exists():p.error('refusing non-checkout destination; choose an empty source path') source.parent.mkdir(parents=True,exist_ok=True) - run(['git','clone',manifest['upstream'],str(source)]) - run(['git','-C',str(source),'checkout','--detach',manifest['revision']]) - # Recursive initialization can spend a long time fetching nested repos (and - # Git LFS assets). Explicit progress keeps the last completed submodule - # from looking like the operation that is currently stuck. The pinned - # darling-swift .lfsconfig points at the retired git-lfs.darlinghq.org, so - # fetch that submodule's LFS objects from GitHub, which still serves them; - # otherwise the checkout blocks on the dead host right after sqlite. - print('Initializing Darling submodules (large repositories/LFS assets may take a while)...',flush=True) - run(['git','-C',str(source),'-c','lfs.url=https://github.com/darlinghq/darling-swift.git/info/lfs', - 'submodule','update','--init','--recursive','--progress']) - print('Darling submodules initialized.',flush=True) + # Fetch the exact pin, not today's master. Publish only a complete checkout; + # failed downloads are removed without touching an existing user checkout. + with tempfile.TemporaryDirectory(prefix='darling-clone-',dir=source.parent) as temporary: + checkout=Path(temporary)/'source' + run(['git','init',str(checkout)]) + run(['git','-C',str(checkout),'remote','add','origin',manifest['upstream']]) + run(['git','-C',str(checkout),'fetch','--depth','1','origin',manifest['revision']]) + run(['git','-C',str(checkout),'checkout','--detach','FETCH_HEAD']) + print('Initializing Darling submodules (large repositories/LFS assets may take a while)...',flush=True) + for attempt in range(1,4): + try: + run(['git','-C',str(checkout),'-c','lfs.url=https://github.com/darlinghq/darling-swift.git/info/lfs', + 'submodule','update','--init','--recursive','--depth','1','--progress']) + break + except subprocess.CalledProcessError: + if attempt==3:raise + print(f'Submodule fetch interrupted (attempt {attempt}/3). Retrying...',flush=True) + checkout.rename(source) + # Never reset someone else's checkout to make a patch apply. for component in manifest['components']: repo=source/component['path'] diff --git a/scripts/run.sh b/scripts/run.sh index 56b3665..08f513b 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -2,6 +2,11 @@ set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" +if [ -n "${DARLING_WORKSPACE:-}" ] && ! python3 scripts/nix-state.py check; then + echo 'Rootless runtime readiness is missing or incompatible; run darling-lol --installer in a terminal.' >&2 + exit 1 +fi +export PATH="${DARLING_RUNNER_DIR:+$DARLING_RUNNER_DIR:}$repo/usr-local/bin:/usr/local/bin:$PATH" python3 scripts/configure.py >/dev/null if [ -d build/venv/bin ]; then PATH="$repo/build/venv/bin:$PATH"; export PATH; fi # darling-lol perf: optionally wrap with gamemoderun for automatic diff --git a/scripts/update.sh b/scripts/update.sh index 5658d37..f8e997c 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -1,10 +1,29 @@ #!/bin/sh # Bring an existing build and installation up to date after `git pull`: re-apply the patches that changed # (prepare.py reverts the earlier version it finds applied), rebuild incrementally, and reinstall the base -# runtime and the per-user overrides. Run it with no Riot/League session open; it never stops one. +# runtime, and the per-user overrides. Run it after manually closing Riot/League. +# Rootless callers must remain in their original namespace; the outer launcher +# refuses a live prefix marker instead of trying to reconnect or stop a session. set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" +if [ -n "${DARLING_WORKSPACE:-}" ]; then + python3 scripts/nix-idle.py + # Initial setup and upgrades use the same supported user-owned install path. + # Keep each package closure rooted: copied ELF files can retain its loader. + package=${DARLING_PACKAGE_ID%/share/darling-lol} + case "$package" in /nix/store/*) ;; *) echo 'Missing Nix package identity' >&2; exit 1;; esac + mkdir -p build/nix-gcroots + nix-store --realise "$package" --add-root "$repo/build/nix-gcroots/$(basename "$package")" --indirect >/dev/null + python3 scripts/nix-state.py invalidate-base + ./scripts/build.sh + python3 build/runtime/tools/stop-darling-runtime.py + cmake --install build/darling-src/build + python3 scripts/nix-state.py stamp-base + ./scripts/install-runtime.sh + echo 'Rootless update installed. In this shell use scripts/run.sh; otherwise exit this shell and start darling-lol.' + exit 0 +fi if [ ! -d build/darling-src/build ]; then echo 'No existing build; follow the first-time setup in README.md instead.' >&2 exit 1 diff --git a/scripts/validate.py b/scripts/validate.py index df0584b..57fcf9f 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -12,6 +12,9 @@ errors=[] secret_patterns=[r'gh[pousr]_[A-Za-z0-9]{30,}',r'AKIA[0-9A-Z]{16}',r'eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}',r'-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----'] for name in filter(None,files): p=ROOT/name;data=p.read_bytes() + # The Nix desktop package intentionally ships this icon, not a runtime binary. + if name == 'darling-lol.png' and data.startswith(b'\x89PNG\r\n\x1a\n'): + continue if data.startswith((b'\x7fELF',b'MZ',b'\xcf\xfa\xed\xfe',b'\xca\xfe\xba\xbe')) or b'\0' in data: errors.append(name+': binary content is not part of this source package');continue try:text=data.decode() diff --git a/tests/test_nix_launcher.py b/tests/test_nix_launcher.py new file mode 100644 index 0000000..b4190fc --- /dev/null +++ b/tests/test_nix_launcher.py @@ -0,0 +1,90 @@ +"""Regression tests for the Nix launcher; no root or game account required.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class LauncherTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.folder = Path(self.tmp.name) + self.home = self.folder / 'home' + self.home.mkdir() + self.share = self.folder / 'share' + (self.share / 'scripts').mkdir(parents=True) + (self.share / 'scripts/run.sh').write_text('new packaged script\n') + import shutil + shutil.copy(ROOT / 'scripts/nix-copy.py', self.share / 'scripts/nix-copy.py') + self.workspace = self.home / '.local/share/darling-lol' + self.log = self.folder / 'calls' + self.bin = self.folder / 'bin' + self.bin.mkdir() + for name in ['unshare', 'fhs']: + executable = self.bin / name + executable.write_text('#!/bin/sh\nprintf "%s\\n" "$0" "$@" > "$TEST_LOG"\n') + executable.chmod(0o755) + self.launcher = self.folder / 'launcher' + self.launcher.write_text((ROOT / 'darling-lol-launcher.sh').read_text() + .replace('@SHARE_DIR@', str(self.share)) + .replace('@FHS_BIN@', str(self.bin / 'fhs')) + .replace('@UNSHARE_BIN@', str(self.bin / 'unshare'))) + self.env = dict(os.environ, HOME=str(self.home), XDG_DATA_HOME=str(self.home / '.local/share'), + TEST_LOG=str(self.log), PATH=str(self.bin) + ':' + os.environ['PATH']) + + def launch(self, *args): + return subprocess.run(['bash', str(self.launcher), *args], env=self.env, + text=True, capture_output=True, timeout=10) + + def test_package_upgrade_refreshes_tools_without_touching_build(self): + (self.workspace / 'scripts').mkdir(parents=True) + (self.workspace / 'scripts/run.sh').write_text('stale script\n') + (self.workspace / 'build').mkdir() + binary = self.workspace / 'build/keep-me' + binary.write_text('compiled runtime') + result = self.launch('true') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((self.workspace / 'scripts/run.sh').read_text(), 'new packaged script\n') + self.assertEqual(binary.read_text(), 'compiled runtime') + + def test_fhs_enters_subuid_namespace_before_bubblewrap(self): + result = self.launch('echo', 'hello world') + self.assertEqual(result.returncode, 0, result.stderr) + calls = self.log.read_text().splitlines() + self.assertEqual(calls[0], str(self.bin / 'unshare')) + self.assertIn('--map-auto', calls) + self.assertIn('--map-current-user', calls) + self.assertIn('--setuid=0', calls) + self.assertIn('--setgid=0', calls) + self.assertEqual(calls[-3:], [str(self.bin / 'fhs'), 'echo', 'hello world']) + + def test_setup_never_makes_a_user_writable_runtime_setuid(self): + result = self.launch('--installer') + self.assertEqual(result.returncode, 0, result.stderr) + setup = (ROOT / 'scripts/nix-session.sh').read_text() + (ROOT / 'scripts/update.sh').read_text().split('if [ ! -d build/darling-src/build ]; then')[0] + self.assertNotIn(' sudo ', setup) + self.assertNotIn('\nsudo ', setup) + self.assertNotIn('chmod 4755', setup) + self.assertIn('install-runtime.sh', setup) + + +class RuntimeEntryTests(unittest.TestCase): + def test_entry_preserves_host_identity_and_uses_namespace_only_privileges(self): + entry = ROOT / 'scripts/fhs-entry.sh' + self.assertTrue(entry.is_file(), 'missing rootless FHS entrypoint') + text = entry.read_text() + self.assertIn('DARLING_HOST_UID', text) + self.assertIn('DARLING_HOST_GID', text) + self.assertIn('--ambient-caps', text) + shim = ROOT / 'scripts/darling-rootless.sh' + self.assertTrue(shim.is_file(), 'missing namespace-local Darling shim') + self.assertIn('setpriv', shim.read_text()) + self.assertIn('--euid', shim.read_text()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_nix_repairs.py b/tests/test_nix_repairs.py new file mode 100644 index 0000000..f0ee4f5 --- /dev/null +++ b/tests/test_nix_repairs.py @@ -0,0 +1,519 @@ +"""Safe packaging regressions: only temporary repositories, homes and stub commands.""" +import hashlib +import importlib.util +import json +import getpass +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +def run(*args, cwd=None, env=None): + return subprocess.run(args, cwd=cwd, env=env, input='', text=True, + capture_output=True, timeout=30) + + +def git(repo, *args): + result = run('git', '-C', str(repo), *args) + if result.returncode: + raise AssertionError(result.stderr) + return result.stdout.strip() + + +def init(repo): + repo.mkdir(parents=True) + git(repo, 'init', '-b', 'master') + git(repo, 'config', 'user.name', 'Fixture') + git(repo, 'config', 'user.email', 'fixture@example.invalid') + + +def shutdown_patch(): + return 'diff --git a/tools/shutdown-user.sh b/tools/shutdown-user.sh\n' + ( + ROOT / 'patches/darling.patch').read_text().split( + 'diff --git a/tools/shutdown-user.sh b/tools/shutdown-user.sh\n', 1)[1].split('\ndiff --git ', 1)[0] + '\n' + + +def patch_base(patch): + lines = patch.splitlines() + start = next(i for i, line in enumerate(lines) if line.startswith('@@')) + 1 + return '\n'.join(line[1:] for line in lines[start:] if line.startswith((' ', '-'))) + '\n' + + +class PrepareTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='nix-prepare-test-') + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.project = self.root / 'project' + for name in ['scripts', 'patches', 'upstream']: + (self.project / name).mkdir(parents=True) + for name in ['prepare.py', 'common.py']: + shutil.copy(ROOT / 'scripts' / name, self.project / 'scripts' / name) + self.source = self.root / 'source' + init(self.source) + self.env = dict(os.environ, PYTHONDONTWRITEBYTECODE='1', HOME=str(self.root)) + + def prepare(self, check=False): + return run(sys.executable, 'scripts/prepare.py', '--source', str(self.source), + *(['--check-only'] if check else []), cwd=self.project, env=self.env) + + def manifest(self, revision, components, upstream=None): + (self.project / 'upstream/manifest.json').write_text(json.dumps({ + 'upstream': upstream or self.source.as_uri(), 'revision': revision, 'components': components})) + + def test_prepare_twice_and_check_only_preserves_all_files(self): + patch = shutdown_patch() + (self.source / 'tools').mkdir() + (self.source / 'tools/shutdown-user.sh').write_text(patch_base(patch)) + git(self.source, 'add', '.') + git(self.source, 'commit', '-m', 'base') + rev = git(self.source, 'rev-parse', 'HEAD') + (self.project / 'patches/darling.patch').write_text(patch) + self.manifest(rev, [{'path': '.', 'patch': 'patches/darling.patch', 'revision': rev}]) + for _ in range(2): + result = self.prepare() + self.assertEqual(result.returncode, 0, result.stderr) + before = {str(p): (p.read_bytes(), p.stat().st_mtime_ns) for p in self.root.rglob('*') if p.is_file()} + result = self.prepare(True) + self.assertEqual(result.returncode, 0, result.stderr) + after = {str(p): (p.read_bytes(), p.stat().st_mtime_ns) for p in self.root.rglob('*') if p.is_file()} + self.assertEqual(before, after) + + def test_check_only_does_not_rewrite_compatibility_headers(self): + path = self.source / 'src/external/cctools-port/cctools/ld64/src/3rd/strlcpy.h' + path.parent.mkdir(parents=True) + path.write_text('size_t strlcpy(char *dst, const char *src, size_t siz);\n') + git(self.source, 'add', '.') + git(self.source, 'commit', '-m', 'header') + self.manifest(git(self.source, 'rev-parse', 'HEAD'), []) + before = path.read_bytes(), path.stat().st_mtime_ns + self.assertEqual(self.prepare(True).returncode, 0) + self.assertEqual(before, (path.read_bytes(), path.stat().st_mtime_ns)) + + def test_rootless_shutdown_dispatches_only_scoped_helper_and_propagates_failure(self): + with tempfile.TemporaryDirectory(prefix='nix-hook-test-') as directory: + root = Path(directory) + (root / 'scripts').mkdir() + helper = root / 'scripts/nix-idle.py' + helper.write_text('import os, sys\nprint(os.environ["DPREFIX"])\nsys.exit(int(os.environ["TEST_EXIT"]))\n') + patch = shutdown_patch().splitlines() + start = next(i for i, line in enumerate(patch) if line.startswith('@@')) + 1 + script = root / 'shutdown.sh' + script.write_text('\n'.join(line[1:] for line in patch[start:] if line.startswith((' ', '+'))) + '\n') + bin_dir = root / 'bin' + bin_dir.mkdir() + # These commands must never be reached by rootless preparation. + for name in ['sudo', 'ps', 'su', 'darling', 'killall', 'pkill']: + stub = bin_dir / name + stub.write_text('#!/bin/sh\necho unexpected-' + name + ' >&2\nexit 99\n') + stub.chmod(0o755) + env = dict(os.environ, DARLING_WORKSPACE=str(root), DPREFIX=str(root / 'prefix'), + TEST_EXIT='0', PATH=str(bin_dir) + ':' + os.environ['PATH']) + success = run('sh', str(script), env=env) + self.assertEqual(success.returncode, 0, success.stderr) + self.assertEqual(success.stdout.strip(), env['DPREFIX']) + env['TEST_EXIT'] = '23' + self.assertEqual(run('sh', str(script), env=env).returncode, 23) + + def test_cctools_patch_is_maintained_and_repeatable(self): + component = next(c for c in json.loads((ROOT / 'upstream/manifest.json').read_text())['components'] + if c['path'] == 'src/external/cctools-port') + patch = (ROOT / component['patch']).read_text() + # Reconstruct exactly the base of each maintained hunk in a local repo. + for section in patch.split('diff --git ')[1:]: + lines = section.splitlines() + path = lines[0].split(' b/')[1] + header = next(i for i, line in enumerate(lines) if line.startswith('@@')) + # Header hunk starts at line six; use a neutral prefix of that length. + start = int(lines[header].split('-')[1].split(',')[0]) + base = ['/* untouched */\n'] * (start - 1) + base += [line[1:] + '\n' if line else '\n' for line in lines[header + 1:] if not line or line.startswith((' ', '-'))] + target = self.source / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(''.join(base)) + git(self.source, 'add', '.') + git(self.source, 'commit', '-m', 'cctools') + rev = git(self.source, 'rev-parse', 'HEAD') + (self.project / 'patches/cctools-port.patch').write_text(patch) + self.manifest(rev, [{'path': '.', 'revision': rev, 'patch': 'patches/cctools-port.patch'}]) + for check in [False, False, True]: + result = self.prepare(check) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_fetch_pin_older_than_advanced_remote_and_failed_retry(self): + (self.source / 'file').write_text('pinned') + git(self.source, 'add', '.') + git(self.source, 'commit', '-m', 'pin') + pin = git(self.source, 'rev-parse', 'HEAD') + (self.source / 'file').write_text('advanced') + git(self.source, 'commit', '-am', 'advance') + remote = self.source + self.source = self.root / 'clone' + self.manifest('0' * 40, [], remote.as_uri()) + failed = self.prepare() + self.assertNotEqual(failed.returncode, 0) + self.assertFalse(self.source.exists(), 'failed initial checkout must not poison retries') + self.manifest(pin, [], remote.as_uri()) + result = self.prepare() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(git(self.source, 'rev-parse', 'HEAD'), pin) + self.assertEqual((self.source / 'file').read_text(), 'pinned') + + +class CopyTests(unittest.TestCase): + def test_symlink_workspace_is_rejected_without_external_writes(self): + with tempfile.TemporaryDirectory(prefix='nix-symlink-test-') as directory: + root = Path(directory) + share = root / 'share' + (share / 'scripts').mkdir(parents=True) + shutil.copy(ROOT / 'scripts/nix-copy.py', share / 'scripts/nix-copy.py') + outside = root / 'outside' + outside.mkdir() + data = root / 'data' + data.mkdir() + (data / 'darling-lol').symlink_to(outside, target_is_directory=True) + launcher = root / 'launcher' + launcher.write_text((ROOT / 'darling-lol-launcher.sh').read_text().replace('@SHARE_DIR@', str(share))) + env = dict(os.environ, HOME=str(root), XDG_DATA_HOME=str(data), DPREFIX=str(root / 'prefix')) + result = run('bash', str(launcher), 'true', env=env) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(list(outside.iterdir()), []) + + def test_copied_scripts_execute_and_upgrade_without_following_symlinks(self): + with tempfile.TemporaryDirectory(prefix='nix-copy-test-') as directory: + root = Path(directory) + share, workspace = root / 'share', root / 'workspace' + (share / 'scripts').mkdir(parents=True) + shutil.copy(ROOT / 'scripts/nix-copy.py', share / 'scripts/nix-copy.py') + entry = share / 'scripts/run.sh' + entry.write_text('#!/bin/sh\nprintf first\n') + entry.chmod(0o555) + env = dict(os.environ, HOME=str(root), XDG_DATA_HOME=str(root / 'data')) + # Use real cp/filesystem execution, but never a real namespace or Darling. + launcher = root / 'launcher' + launcher.write_text((ROOT / 'darling-lol-launcher.sh').read_text() + .replace('@SHARE_DIR@', str(share)).replace('@FHS_BIN@', shutil.which('true')) + .replace('@UNSHARE_BIN@', shutil.which('true')).replace('@TERMINAL_BIN@', shutil.which('false'))) + workspace = root / 'data/darling-lol' + result = run('bash', str(launcher), 'true', env=env) + self.assertEqual(result.returncode, 0, result.stderr) + target = workspace / 'scripts/run.sh' + self.assertTrue(os.access(target, os.X_OK), 'copied entrypoint lost executable bits') + self.assertTrue(target.stat().st_mode & 0o200) + self.assertEqual(run(str(target), env=env).stdout, 'first') + entry.chmod(0o755) + entry.write_text('#!/bin/sh\nprintf second\n') + entry.chmod(0o555) + self.assertEqual(run('bash', str(launcher), 'true', env=env).returncode, 0) + self.assertEqual(run(str(target), env=env).stdout, 'second') + external = root / 'external' + external.write_text('untouched') + target.unlink() + target.symlink_to(external) + result = run('bash', str(launcher), 'true', env=env) + self.assertNotEqual(result.returncode, 0, 'reject managed destination symlinks') + self.assertEqual(external.read_text(), 'untouched') + + +def populate_overlays(prefix, home, tools): + for name in ('validate-riot-runtime.py', 'runtime_paths.py'): + shutil.copy(ROOT / 'tools' / name, tools / name) + sys.path.insert(0, str(tools)) + try: + spec = importlib.util.spec_from_file_location('validation_fixture', tools / 'validate-riot-runtime.py') + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + guest_req = getattr(module, 'GUEST_REQUIRED', getattr(module, 'REQUIRED', ())) + prefix_req = getattr(module, 'PREFIX_REQUIRED', ()) + for relative in guest_req: + path = home / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'\xcf\xfa\xed\xfe' + b'inert test fixture') + for relative in prefix_req: + path = prefix / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'\xcf\xfa\xed\xfe' + b'inert test fixture') + finally: + sys.path.remove(str(tools)) + + +class ReadinessTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='nix-state-test-') + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.repo = self.root / 'repo' + shutil.copytree(ROOT / 'scripts', self.repo / 'scripts', ignore=shutil.ignore_patterns('__pycache__')) + for folder in ['upstream', 'patches', 'tools', 'usr-local/bin']: + (self.repo / folder).mkdir(parents=True) + (self.repo / 'upstream/manifest.json').write_text('{}') + shutil.copy(ROOT / 'tools/runtime_paths.py', self.repo / 'tools/runtime_paths.py') + (self.repo / 'usr-local/bin/darling').write_text('fixture, never executed') + (self.repo / 'usr-local/bin/darlingserver').write_text('server fixture, never executed') + (self.repo / 'usr-local/libexec/darling/usr/lib').mkdir(parents=True) + (self.repo / 'usr-local/libexec/darling/usr/lib/libSystem.B.dylib').write_text('SDK fixture') + for name in ['darling', 'darlingserver']: + (self.repo / 'usr-local/bin' / name).chmod(0o755) + (self.repo / 'usr-local/libexec/darling').mkdir(parents=True, exist_ok=True) + (self.repo / 'build').mkdir() + (self.repo / 'build/config.json').write_text(json.dumps({'guest_user': 'guest-one'})) + self.env = dict(os.environ, HOME=str(self.root), DPREFIX=str(self.root / 'prefix'), + DARLING_WORKSPACE=str(self.repo), DARLING_PACKAGE_ID='package-one', + DARLING_GUEST_USER='guest-one', PYTHONDONTWRITEBYTECODE='1') + self.env.pop('RIOT_GUEST_HOME', None) + populate_overlays(Path(self.env['DPREFIX']), Path(self.env['DPREFIX']) / 'Users/guest-one', self.repo / 'tools') + + def state(self, action): + return run(sys.executable, str(self.repo / 'scripts/nix-state.py'), action, env=self.env) + + def test_prefix_guest_package_source_and_runtime_identity(self): + self.assertNotEqual(self.state('check').returncode, 0) + (Path(self.env['DPREFIX']) / 'Applications/Riot Client.app').mkdir(parents=True) + self.assertNotEqual(self.state('check').returncode, 0, 'existing app is not readiness') + self.assertEqual(self.state('stamp-base').returncode, 0) + self.assertEqual(self.state('stamp').returncode, 0) + self.assertEqual(self.state('check').returncode, 0) + # Canonical aliases share readiness, but a different prefix must not. + self.env['DPREFIX'] += '/../prefix' + self.assertEqual(self.state('check').returncode, 0) + for key, value in [('DPREFIX', str(self.root / 'new-prefix')), + ('DARLING_PACKAGE_ID', 'package-two')]: + old = self.env[key] + self.env[key] = value + self.assertNotEqual(self.state('check').returncode, 0, key) + self.env[key] = old + (self.repo / 'build/config.json').write_text(json.dumps({'guest_user': 'guest-two'})) + self.assertNotEqual(self.state('check').returncode, 0) + (self.repo / 'build/config.json').write_text(json.dumps({'guest_user': 'guest-one'})) + (self.repo / 'patches/change.patch').write_text('upgrade') + self.assertNotEqual(self.state('check').returncode, 0) + self.assertEqual(self.state('stamp-base').returncode, 0) + self.assertEqual(self.state('stamp').returncode, 0) + (self.repo / 'usr-local/bin/darlingserver').write_text('server-one') + self.assertEqual(self.state('stamp-base').returncode, 0) + self.assertEqual(self.state('stamp').returncode, 0) + (self.repo / 'usr-local/bin/darlingserver').write_text('server-two') + self.assertNotEqual(self.state('check').returncode, 0) + (self.repo / 'usr-local/bin/darling').write_text('changed runtime') + self.assertNotEqual(self.state('check').returncode, 0) + + def test_deleted_overlay_invalidates_readiness(self): + overlay = Path(self.env['DPREFIX']) / 'Users/guest-one/RuntimeLibraries/library.dylib' + overlay.parent.mkdir(parents=True, exist_ok=True) + overlay.write_text('fixture overlay') + self.assertEqual(self.state('stamp-base').returncode, 0) + self.assertEqual(self.state('stamp').returncode, 0) + self.assertEqual(self.state('check').returncode, 0) + overlay.unlink() + self.assertNotEqual(self.state('check').returncode, 0) + + def test_missing_runtime_and_noop_checks_do_not_create_or_rebuild(self): + self.assertEqual(self.state('stamp-base').returncode, 0) + self.assertEqual(self.state('stamp').returncode, 0) + before = {str(p): p.stat().st_mtime_ns for p in self.repo.rglob('*') if p.is_file()} + for _ in range(2): + self.assertEqual(self.state('check').returncode, 0) + after = {str(p): p.stat().st_mtime_ns for p in self.repo.rglob('*') if p.is_file()} + self.assertEqual(before, after) + (self.repo / 'usr-local/bin/darling').unlink() + self.assertNotEqual(self.state('check').returncode, 0) + self.assertNotEqual(self.state('stamp-base').returncode, 0) + + def test_interrupted_setup_invalidates_previous_stamp(self): + self.assertEqual(self.state('stamp-base').returncode, 0) + self.assertEqual(self.state('stamp').returncode, 0) + self.assertEqual(self.state('invalidate').returncode, 0) + self.assertNotEqual(self.state('check').returncode, 0) + + def test_runner_refuses_unready_even_for_raw_launch_paths(self): + runner = ROOT / 'scripts/darling-rootless.sh' + env = dict(self.env, DARLING_HOST_UID=str(os.getuid())) + result = run('bash', str(runner), 'shell', '/usr/bin/true', env=env) + self.assertNotEqual(result.returncode, 0) + self.assertIn('readiness', result.stderr) + + def test_run_and_installer_refuse_unready_before_configure_or_darling(self): + for name in ['run.sh', 'installer.sh']: + result = run('sh', str(self.repo / 'scripts' / name), env=self.env) + self.assertNotEqual(result.returncode, 0) + self.assertIn('readiness', result.stderr.lower()) + self.assertFalse((self.repo / 'build/runtime').exists()) + + +class SessionTests(unittest.TestCase): + def test_workspace_lock_survives_namespace_closing_extra_fds(self): + with tempfile.TemporaryDirectory(prefix='nix-lock-test-') as directory: + root = Path(directory) + share = root / 'share' + (share / 'scripts').mkdir(parents=True) + shutil.copy(ROOT / 'scripts/nix-copy.py', share / 'scripts/nix-copy.py') + stub = root / 'namespace' + stub.write_text('#!/bin/sh\nexec 9>&-\ntouch "$TEST_ENTERED"\nsleep 1\n') + stub.chmod(0o755) + launcher = root / 'launcher' + launcher.write_text((ROOT / 'darling-lol-launcher.sh').read_text() + .replace('@SHARE_DIR@', str(share)).replace('@UNSHARE_BIN@', str(stub))) + entered = root / 'entered' + env = dict(os.environ, HOME=str(root), XDG_DATA_HOME=str(root / 'data'), TEST_ENTERED=str(entered)) + first = subprocess.Popen(['bash', str(launcher), 'true'], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + import time + for _ in range(100): + if entered.exists(): + break + time.sleep(.01) + self.assertTrue(entered.exists()) + second = run('bash', str(launcher), 'true', env=env) + self.assertNotEqual(second.returncode, 0) + self.assertIn('session is active', second.stderr) + finally: + first.communicate(timeout=10) + + def test_live_prefix_marker_refuses_before_refresh_or_namespace(self): + with tempfile.TemporaryDirectory(prefix='nix-marker-test-') as directory: + root = Path(directory) + prefix = root / 'prefix' + prefix.mkdir() + # This fixture's own PID is harmless; the launcher must never signal it. + (prefix / '.init.pid').write_text(str(os.getpid())) + launcher = root / 'launcher' + launcher.write_text((ROOT / 'darling-lol-launcher.sh').read_text() + .replace('@SHARE_DIR@', str(root / 'missing-share')) + .replace('@UNSHARE_BIN@', 'must-not-execute')) + env = dict(os.environ, HOME=str(root), DPREFIX=str(prefix), XDG_DATA_HOME=str(root / 'data')) + result = run('bash', str(launcher), env=env) + self.assertNotEqual(result.returncode, 0) + self.assertIn('reconnect is unsupported', result.stderr) + self.assertFalse((root / 'data/darling-lol/scripts').exists()) + + def test_ready_session_launches_without_rebuild_and_missing_stamp_refuses_eof(self): + with tempfile.TemporaryDirectory(prefix='nix-dispatch-test-') as directory: + root = Path(directory) + (root / 'scripts').mkdir() + for name in ['nix-state.py', 'nix-session.sh']: + shutil.copy(ROOT / 'scripts' / name, root / 'scripts' / name) + (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') + for name in ['darling', 'darlingserver']: + (root / 'usr-local/bin' / name).chmod(0o755) + (root / 'usr-local/libexec/darling/usr/lib').mkdir(parents=True, exist_ok=True) + (root / 'usr-local/libexec/darling/usr/lib/libSystem.B.dylib').write_text('SDK fixture') + (root / 'tools').mkdir() + (root / 'usr-local/libexec/darling').mkdir(parents=True, exist_ok=True) + shutil.copy(ROOT / 'tools/runtime_paths.py', root / 'tools/runtime_paths.py') + (root / 'prefix/Applications/Riot Client.app').mkdir(parents=True) + for name in ['run.sh', 'update.sh', 'install-runtime.sh', 'installer.sh']: + path = root / 'scripts' / name + path.write_text('#!/bin/sh\necho ' + name + ' >> "$TEST_LOG"\n') + path.chmod(0o755) + log = root / 'calls' + env = dict(os.environ, HOME=str(root), DPREFIX=str(root / 'prefix'), + DARLING_WORKSPACE=str(root), DARLING_PACKAGE_ID='fixture', TEST_LOG=str(log), + PYTHONDONTWRITEBYTECODE='1') + env.pop('RIOT_GUEST_HOME', None) + populate_overlays(root / 'prefix', root / 'prefix/Users' / getpass.getuser(), root / 'tools') + for action in ['stamp-base', 'stamp']: + self.assertEqual(run(sys.executable, str(root / 'scripts/nix-state.py'), action, env=env).returncode, 0) + for _ in range(2): + result = run('bash', str(root / 'scripts/nix-session.sh'), env=env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(log.read_text().splitlines(), ['run.sh', 'run.sh']) + run(sys.executable, str(root / 'scripts/nix-state.py'), 'invalidate', env=env) + result = run('bash', str(root / 'scripts/nix-session.sh'), env=env) + self.assertNotEqual(result.returncode, 0) + self.assertIn('terminal', result.stderr) + self.assertEqual(log.read_text().splitlines(), ['run.sh', 'run.sh']) + env['DPREFIX'] = str(root / 'other-prefix') + result = run('bash', str(root / 'scripts/nix-session.sh'), env=env) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((root / 'other-prefix').exists()) + + def test_first_run_eof_is_actionable_and_desktop_requests_terminal(self): + with tempfile.TemporaryDirectory(prefix='nix-session-test-') as directory: + root = Path(directory) + (root / 'scripts').mkdir() + for p in (ROOT / 'scripts').glob('*'): + if p.is_file(): + shutil.copy(p, root / 'scripts' / p.name) + env = dict(os.environ, HOME=str(root), DPREFIX=str(root / 'prefix'), + DARLING_WORKSPACE=str(root), DARLING_PACKAGE_ID='fixture') + result = run('bash', str(root / 'scripts/nix-session.sh'), env=env) + self.assertNotEqual(result.returncode, 0) + self.assertIn('terminal', result.stderr.lower()) + self.assertFalse((root / 'build').exists()) + self.assertIn('Terminal=true', (ROOT / 'flake.nix').read_text()) + self.assertIn('checks.${system}.packaging', (ROOT / 'flake.nix').read_text()) + + def test_rootless_update_refuses_live_marker_without_build_or_stop(self): + with tempfile.TemporaryDirectory(prefix='nix-update-idle-') as directory: + root = Path(directory) + (root / 'scripts').mkdir() + for name in ['update.sh', 'nix-idle.py']: + if (ROOT / 'scripts' / name).exists(): + shutil.copy(ROOT / 'scripts' / name, root / 'scripts' / name) + prefix = root / 'prefix' + prefix.mkdir() + (prefix / '.init.pid').write_text(str(os.getpid())) + env = dict(os.environ, DARLING_WORKSPACE=str(root), DPREFIX=str(prefix), + DARLING_PACKAGE_ID='/nix/store/fixture-package/share/darling-lol') + result = run('sh', str(root / 'scripts/update.sh'), env=env) + self.assertNotEqual(result.returncode, 0) + self.assertIn('live server', result.stderr.lower()) + self.assertFalse((root / 'build').exists()) + + def test_rootless_update_executes_without_sudo_and_fails_closed(self): + with tempfile.TemporaryDirectory(prefix='nix-update-test-') as directory: + root = Path(directory) + scripts, tools, bin_dir = root / 'scripts', root / 'build/runtime/tools', root / 'bin' + scripts.mkdir() + tools.mkdir(parents=True) + bin_dir.mkdir() + shutil.copy(ROOT / 'scripts/update.sh', scripts / 'update.sh') + # Process discovery has its own read-only marker test; isolate this + # installer-order fixture from unrelated host /proc permissions. + (scripts / 'nix-idle.py').write_text('pass\n') + log = root / 'calls' + for name, body in [('build.sh', 'echo build >> "$TEST_LOG"'), + ('install-runtime.sh', 'echo overlays >> "$TEST_LOG"')]: + path = scripts / name + path.write_text('#!/bin/sh\n' + body + '\n') + path.chmod(0o755) + (scripts / 'nix-state.py').write_text('import os, sys\nwith open(os.environ["TEST_LOG"], "a") as f: f.write(sys.argv[1]+"\\n")\n') + (tools / 'stop-darling-runtime.py').write_text('import os\nwith open(os.environ["TEST_LOG"], "a") as f: f.write("stop\\n")\n') + for name in ['cmake', 'sudo', 'nix-store']: + path = bin_dir / name + path.write_text('#!/bin/sh\necho ' + name + ' >> "$TEST_LOG"\n' + + ('exit "${INSTALL_EXIT:-0}"\n' if name == 'cmake' else 'exit 0\n')) + path.chmod(0o755) + env = dict(os.environ, DARLING_WORKSPACE=str(root), HOME=str(root), DPREFIX=str(root / 'prefix'), + DARLING_PACKAGE_ID='/nix/store/fixture-package/share/darling-lol', + TEST_LOG=str(log), PATH=str(bin_dir) + ':' + os.environ['PATH']) + result = run('sh', str(scripts / 'update.sh'), env=env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(log.read_text().splitlines(), + ['nix-store', 'invalidate-base', 'build', 'stop', 'cmake', 'stamp-base', 'overlays']) + log.write_text('') + env['INSTALL_EXIT'] = '23' + self.assertEqual(run('sh', str(scripts / 'update.sh'), env=env).returncode, 23) + self.assertNotIn('stamp-base', log.read_text()) + self.assertNotIn('overlays', log.read_text()) + self.assertNotIn('sudo', log.read_text()) + + def test_rootless_update_never_uses_sudo_and_stamps_only_after_success(self): + text = (ROOT / 'scripts/update.sh').read_text() + self.assertIn('DARLING_WORKSPACE', text) + self.assertIn('stamp-base', text) + self.assertIn('invalidate-base', text) + self.assertIn('nix-state.py', text) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_nix_runtime_identity.py b/tests/test_nix_runtime_identity.py new file mode 100644 index 0000000..e292085 --- /dev/null +++ b/tests/test_nix_runtime_identity.py @@ -0,0 +1,111 @@ +"""Readiness regression fixtures: inert runtime bytes, no Darling execution.""" +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class RuntimeIdentityTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='nix-identity-test-') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + for name in ('scripts', 'tools'): + shutil.copytree(ROOT / name, self.root / name) + self.prefix = self.root / 'prefix' + self.guest = self.prefix / 'Users/tester' + (self.guest / 'RuntimeLibraries').mkdir(parents=True) + (self.guest / 'RuntimeFrameworks').mkdir() + (self.guest / 'RuntimeLibraries/test.dylib').write_bytes(b'\xcf\xfa\xed\xfe' + b'fixture') + (self.guest / 'libDarlingXShm.dylib').write_bytes(b'\xcf\xfa\xed\xfe' + b'fixture') + audio = self.guest / 'Library/Audio/Plug-Ins/Components/DarlingRiotAudio.component/Contents/MacOS/CoreAudio' + audio.parent.mkdir(parents=True) + audio.write_bytes(b'\xcf\xfa\xed\xfe' + b'fixture') + spec = importlib.util.spec_from_file_location('validation_fixture', self.root / 'tools/validate-riot-runtime.py') + sys.path.insert(0, str(self.root / 'tools')) + self.addCleanup(lambda: sys.path.remove(str(self.root / 'tools'))) + validation = importlib.util.module_from_spec(spec) + spec.loader.exec_module(validation) + guest_req = getattr(validation, 'GUEST_REQUIRED', getattr(validation, 'REQUIRED', ())) + prefix_req = getattr(validation, 'PREFIX_REQUIRED', ()) + for relative in guest_req: + path = self.guest / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'\xcf\xfa\xed\xfe' + b'inert fixture') + for relative in prefix_req: + path = self.prefix / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'\xcf\xfa\xed\xfe' + b'inert fixture') + for name in ('usr-local/bin/darling', 'usr-local/bin/darlingserver', + 'usr-local/libexec/darling/usr/lib/libSystem.B.dylib'): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('inert runtime fixture\n') + path.chmod(0o755) + (self.root / 'build').mkdir() + self.config = self.root / 'build/config.json' + self.config.write_text(json.dumps({'prefix': str(self.prefix), 'guest_user': 'tester', + 'guest_home': '/Users/tester'})) + self.env = dict(os.environ, DPREFIX=str(self.prefix), + DARLING_PACKAGE_ID='/nix/store/fixture-package/share/darling-lol') + self.env.pop('RIOT_GUEST_HOME', None) + for action in ('stamp-base', 'stamp'): + result = self.state(action) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.state('check').returncode, 0) + + def state(self, action, env=None): + return subprocess.run([sys.executable, '-B', 'scripts/nix-state.py', action], + cwd=self.root, env=env or self.env, text=True, + capture_output=True, timeout=20) + + def test_host_guest_home_override_invalidates_readiness(self): + env = dict(self.env, RIOT_GUEST_HOME=str(self.prefix / 'Users/alternate')) + self.assertNotEqual(self.state('check', env).returncode, 0) + + def test_configured_guest_home_change_invalidates_readiness(self): + config = json.loads(self.config.read_text()) + config['guest_home'] = '/Users/alternate' + self.config.write_text(json.dumps(config)) + self.assertNotEqual(self.state('check').returncode, 0) + + def test_required_xshm_loss_invalidates_readiness(self): + (self.guest / 'libDarlingXShm.dylib').unlink() + self.assertNotEqual(self.state('check').returncode, 0) + + def test_audio_component_loss_invalidates_readiness(self): + shutil.rmtree(self.guest / 'Library') + self.assertNotEqual(self.state('check').returncode, 0) + + def test_launcher_execute_permission_loss_invalidates_readiness(self): + (self.root / 'usr-local/bin/darling').chmod(0o644) + self.assertNotEqual(self.state('check').returncode, 0) + + def test_base_runtime_tree_loss_invalidates_readiness(self): + shutil.rmtree(self.root / 'usr-local/libexec') + self.assertNotEqual(self.state('check').returncode, 0) + + def test_missing_base_server_cannot_be_restamped(self): + (self.root / 'usr-local/bin/darlingserver').unlink() + self.assertNotEqual(self.state('stamp-base').returncode, 0) + + def test_empty_prefix_cannot_be_stamped(self): + shutil.rmtree(self.prefix) + self.prefix.mkdir() + self.assertNotEqual(self.state('stamp').returncode, 0) + + def test_configured_prefix_used_without_environment_override(self): + env = dict(self.env) + env.pop('DPREFIX') + self.assertEqual(self.state('check', env).returncode, 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/upstream/manifest.json b/upstream/manifest.json index 09e7fa8..8a83a47 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -246,7 +246,6 @@ "private-include/Metal/MTLArgumentInternal.h", "private-include/Metal/MTLBlitCommandEncoderInternal.h", "private-include/Metal/MTLDepthStencilInternal.h", - "private-include/Metal/MTLDeviceInfo.h", "private-include/Metal/MTLSamplerStateInternal.h", "private-include/Metal/MTLVertexDescriptorInternal.h", "src/Metal/MTLArgument.mm", @@ -317,6 +316,11 @@ "patch": "patches/cfnetwork.patch", "modified_files": 1, "new_files": [] + }, + { + "path": "src/external/cctools-port", + "revision": "d9456c221e1f462e17c0b3297748bc089d5a861e", + "patch": "patches/cctools-port.patch" } ], "omitted": [ From 573f2905522869a1b58664186f48128b4baeecdc Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Sun, 18 Oct 2026 13:35:15 +0200 Subject: [PATCH 2/4] fix(nix): simplify compiler setup and complete rootless session lifecycle --- docs/NIX.md | 229 ++++++++++++++++++++++++++++++++ docs/SETUP.md | 78 +++-------- flake.nix | 66 +++------ scripts/nix-compiler.sh | 8 ++ scripts/nix-copy.py | 204 +++++++++++++++++++++++----- scripts/nix-idle.py | 85 +++++++----- scripts/nix-lifecycle.py | 78 +++++++++++ scripts/nix-session.sh | 4 +- tests/test_nix_compilers.py | 52 ++++++++ tests/test_nix_copy_manifest.py | 101 ++++++++++++++ tests/test_nix_lifecycle.py | 90 +++++++++++++ tests/test_nix_repairs.py | 5 +- 12 files changed, 824 insertions(+), 176 deletions(-) create mode 100644 docs/NIX.md create mode 100644 scripts/nix-compiler.sh create mode 100644 scripts/nix-lifecycle.py create mode 100644 tests/test_nix_compilers.py create mode 100644 tests/test_nix_copy_manifest.py create mode 100644 tests/test_nix_lifecycle.py 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') From 33581fb216f5dea98647f6eac5b8f74f2ce4a4aa Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Sun, 18 Oct 2026 13:51:58 +0200 Subject: [PATCH 3/4] fix(nix): address review feedback on setup and runtime safety --- docs/NIX.md | 2 ++ docs/SETUP.md | 2 ++ scripts/installer.sh | 3 ++- scripts/nix-lifecycle.py | 22 +++++++++++++++------- scripts/nix-state.py | 6 ++++-- upstream/manifest.json | 1 + 6 files changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/NIX.md b/docs/NIX.md index 853d844..197120d 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -71,6 +71,8 @@ one of the following methods. ```sh nix profile add github:lucannez64/darling-lol#default +# Older Nix/Lix versions without `profile add`: +# nix profile install github:lucannez64/darling-lol#default ``` Then launch from your application menu or terminal: diff --git a/docs/SETUP.md b/docs/SETUP.md index 5998a08..d427c62 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -18,6 +18,8 @@ To install permanently, adding the launcher to your user profile: ```sh nix profile add github:lucannez64/darling-lol#default +# Older Nix/Lix versions without `profile add`: +# nix profile install github:lucannez64/darling-lol#default ``` Then run `darling-lol` or use the desktop application menu. See diff --git a/scripts/installer.sh b/scripts/installer.sh index 48f607c..9bbadbc 100755 --- a/scripts/installer.sh +++ b/scripts/installer.sh @@ -8,7 +8,8 @@ if [ -n "${DARLING_WORKSPACE:-}" ] && ! python3 scripts/nix-state.py check; then fi export PATH="${DARLING_RUNNER_DIR:+$DARLING_RUNNER_DIR:}$repo/usr-local/bin:/usr/local/bin:$PATH" region=${1:-euw} -case "$region" in euw|eune|na|br|kr|jp|lan|las|oce|ru|tr) ;; *) echo 'Use a Riot installer region code' >&2; exit 1;; esac +case "$region" in lan) region=la1;; las) region=la2;; oce) region=oc1;; esac +case "$region" in euw|eune|na|br|kr|jp|la1|la2|oc1|ru|tr) ;; *) echo 'Use a Riot installer region code' >&2; exit 1;; esac python3 scripts/configure.py >/dev/null bash build/runtime/tools/fetch-installer.sh "$region" build/installer unzip -q -o "build/installer/live.$region.zip" -d build/installer diff --git a/scripts/nix-lifecycle.py b/scripts/nix-lifecycle.py index 598ad00..321ef07 100644 --- a/scripts/nix-lifecycle.py +++ b/scripts/nix-lifecycle.py @@ -13,6 +13,14 @@ import sys import time +def warn(message): + """Diagnostics must not discard the namespace after a terminal hangup.""" + try: + print(message, file=sys.stderr, flush=True) + except OSError: + pass + + def main(): scripts = Path(__file__).resolve().parent spec = importlib.util.spec_from_file_location('nix_idle', scripts / 'nix-idle.py') @@ -42,7 +50,7 @@ def main(): if result < 0: result = 128 - result except OSError as error: - print(f'Cannot start client: {error}', file=sys.stderr) + warn(f'Cannot start client: {error}') 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. @@ -54,21 +62,21 @@ def main(): 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) + warn(f'{error}; retaining original namespace and retrying. ' + 'Exit clients to allow safe shutdown.') 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) + warn(f'Cannot run namespace-local shutdown: {error}') 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) + warn(f'Darling namespace-local shutdown failed (status {shutdown}); ' + 'retaining the original namespace and retrying in 5 seconds. ' + 'Keep this terminal open.') time.sleep(5) result = interrupted or result return result diff --git a/scripts/nix-state.py b/scripts/nix-state.py index e52dff4..a9dbab5 100644 --- a/scripts/nix-state.py +++ b/scripts/nix-state.py @@ -71,10 +71,12 @@ def inventory(root): for directory, dirs, files in os.walk(root, followlinks=False, onerror=enumeration_error): + excluded = ('.before-', '.rt-', '.llvm') + dirs[:] = sorted(name for name in dirs if not any(token in name for token in excluded)) for name in sorted(dirs + files): - path = Path(directory) / name - if any(token in path.name for token in ('.before-', '.rt-', '.llvm')): + if any(token in name for token in excluded): continue + path = Path(directory) / name info = path.lstat() target_str = '' if path.is_symlink(): diff --git a/upstream/manifest.json b/upstream/manifest.json index 8a83a47..7ad829f 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -245,6 +245,7 @@ "include/Metal/MTLTrace.h", "private-include/Metal/MTLArgumentInternal.h", "private-include/Metal/MTLBlitCommandEncoderInternal.h", + "private-include/Metal/MTLDeviceInfo.h", "private-include/Metal/MTLDepthStencilInternal.h", "private-include/Metal/MTLSamplerStateInternal.h", "private-include/Metal/MTLVertexDescriptorInternal.h", From 3e67d946c68dafa54de8e00a06695b6b4d73f775 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:54:18 +0200 Subject: [PATCH 4/4] test(nix): cover region routing, stable readiness and lost terminals --- tests/test_installer_regions.py | 33 ++++++++++++++++++++++++++++++ tests/test_nix_lifecycle.py | 21 ++++++++++++++++++- tests/test_nix_runtime_identity.py | 33 ++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 tests/test_installer_regions.py diff --git a/tests/test_installer_regions.py b/tests/test_installer_regions.py new file mode 100644 index 0000000..5d06082 --- /dev/null +++ b/tests/test_installer_regions.py @@ -0,0 +1,33 @@ +"""Stop at a stub fetcher: verify installer routing without network or runtime use.""" +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class InstallerRegions(unittest.TestCase): + def test_aliases_and_platform_codes_reach_the_expected_cdn_region(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + (root / 'scripts').mkdir() + (root / 'build/runtime/tools').mkdir(parents=True) + shutil.copy(ROOT / 'scripts/installer.sh', root / 'scripts') + (root / 'scripts/configure.py').write_text('') + (root / 'build/runtime/tools/fetch-installer.sh').write_text( + 'printf "%s" "$1" > "$TEST_REGION_LOG"\nexit 23\n') + log = root / 'region' + env = dict(os.environ, TEST_REGION_LOG=str(log)) + env.pop('DARLING_WORKSPACE', None) + for region, expected in [('lan', 'la1'), ('las', 'la2'), ('oce', 'oc1'), + ('la1', 'la1'), ('la2', 'la2'), ('oc1', 'oc1'), + ('euw', 'euw')]: + with self.subTest(region=region): + log.unlink(missing_ok=True) + result = subprocess.run(['sh', str(root / 'scripts/installer.sh'), region], + env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 23, result.stderr) + self.assertEqual(log.read_text(), expected) diff --git a/tests/test_nix_lifecycle.py b/tests/test_nix_lifecycle.py index 72ebd3e..f339e07 100644 --- a/tests/test_nix_lifecycle.py +++ b/tests/test_nix_lifecycle.py @@ -6,7 +6,7 @@ import shutil import subprocess import tempfile import unittest -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch ROOT = Path(__file__).resolve().parents[1] @@ -88,3 +88,22 @@ exit "${CLIENT_RC:-0}" self.assertIn('23', result.stderr) self.assertEqual(self.log.read_text().splitlines().count('shutdown:shutdown'), 2) self.assertIn('shutdown:shutdown', self.log.read_text()) + + def test_shutdown_retries_when_terminal_diagnostics_fail(self): + spec = importlib.util.spec_from_file_location('lifecycle_fixture', self.root / 'scripts/nix-lifecycle.py') + lifecycle = importlib.util.module_from_spec(spec) + spec.loader.exec_module(lifecycle) + child = MagicMock() + child.wait.return_value = 17 + stderr = MagicMock() + stderr.write.side_effect = OSError('terminal disconnected') + with patch.dict(os.environ, self.env), \ + patch.object(lifecycle.signal, 'signal'), \ + patch.object(lifecycle.subprocess, 'Popen', return_value=child), \ + patch.object(lifecycle.subprocess, 'run', side_effect=[ + subprocess.CompletedProcess([], 23), subprocess.CompletedProcess([], 0)]) as shutdown, \ + patch.object(lifecycle.time, 'sleep'), \ + patch.object(lifecycle.sys, 'stderr', stderr): + self.assertEqual(lifecycle.main(), 17) + self.assertEqual(shutdown.call_count, 2) + stderr.write.assert_called() diff --git a/tests/test_nix_runtime_identity.py b/tests/test_nix_runtime_identity.py index e292085..e58448c 100644 --- a/tests/test_nix_runtime_identity.py +++ b/tests/test_nix_runtime_identity.py @@ -106,6 +106,39 @@ class RuntimeIdentityTests(unittest.TestCase): env.pop('DPREFIX') self.assertEqual(self.state('check', env).returncode, 0) + def test_inventory_ignores_changes_inside_backup_directories(self): + spec = importlib.util.spec_from_file_location('inventory_fixture', self.root / 'scripts/nix-state.py') + state = importlib.util.module_from_spec(spec) + spec.loader.exec_module(state) + runtime = self.root / 'usr-local/libexec/darling' + for suffix in ('.before-test', '.rt-test', '.llvm-test'): + backup = runtime / ('backup' + suffix) / 'nested' + backup.mkdir(parents=True) + saved = backup / 'saved.dylib' + saved.write_text('old backup') + before = state.inventory(runtime) + saved.write_text('changed backup') + self.assertEqual(state.inventory(runtime), before) + + def test_inventory_is_independent_of_filesystem_directory_order(self): + from unittest.mock import patch + spec = importlib.util.spec_from_file_location('inventory_fixture', self.root / 'scripts/nix-state.py') + state = importlib.util.module_from_spec(spec) + spec.loader.exec_module(state) + runtime = self.root / 'usr-local/libexec/darling' + for name in ('a', 'b'): + folder = runtime / name + folder.mkdir() + (folder / 'file').write_text(name) + original_walk = os.walk + def reversed_walk(*args, **kwargs): + for directory, dirs, files in original_walk(*args, **kwargs): + dirs.reverse() + yield directory, dirs, files + before = state.inventory(runtime) + with patch.object(state.os, 'walk', reversed_walk): + self.assertEqual(state.inventory(runtime), before) + if __name__ == '__main__': unittest.main()