fix(nix): simplify compiler setup and complete rootless session lifecycle

This commit is contained in:
Nicolai Bruns committed 2026-10-18 13:35:15 +02:00
1 parent 3c0b9cdfd3
commit 573f290552
12 files changed
+824 -176

No files matched your search

+8
View File
@@ -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 "$@"
+169 -35
View File
@@ -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}')
+55 -30
View File
@@ -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()
+78
View File
@@ -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())
+2 -2
View File
@@ -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