mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-26 19:47:42 +00:00
fix(nix): simplify compiler setup and complete rootless session lifecycle
This commit is contained in:
1 parent
3c0b9cdfd3
commit
573f290552
12 files changed
+824
-176
No files matched your search
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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
|
||||
Reference in new issue
Block a user