#!/usr/bin/python3 """Bundle utilisateur V2 : préparation, préflight, publication et rollback sans activation.""" import argparse import fcntl import hashlib import json import os from pathlib import Path import stat import tempfile HOME = Path('/home/fy59') SOURCE = Path(__file__).resolve().parents[1] / 'mosaic-login-v2' STATE = HOME / '.local/state/labfy-greeter-mosaic-login-v2' UID = 1000 INSTANCE = r'swayfx\x2dmosaic\x2dtest.desktop' WANTS = HOME / '.dotfiles/systemd/.config/systemd/user' / f'wayland-session@{INSTANCE}.target.wants' # CONTRACT: liste fermée ; le manifeste ne fournit jamais une destination libre. TARGETS = { 'session': (HOME / '.config/swayfx-mosaic-test/session.py', 0o600, 'launcher/session.py'), 'manifest': (HOME / '.config/swayfx-mosaic-test/manifest.json', 0o600, 'launcher/manifest.json'), 'guard': (HOME / '.config/swayfx-mosaic-test/quickshell-guard.py', 0o600, 'launcher/quickshell-guard.py'), 'service': (HOME / '.dotfiles/systemd/.config/systemd/user/quickshell-labfy-mosaic.service', 0o644, 'systemd/quickshell-labfy-mosaic.service'), 'qml': (HOME / '.dotfiles/quickshell/.config/quickshell/labfy-sway/sessionui/SessionV2Service.qml', 0o644, 'quickshell/SessionV2Service.qml'), 'wants': (WANTS / 'quickshell-labfy-mosaic.service', None, None), } LINK = '../quickshell-labfy-mosaic.service' ALIASES = { HOME / '.config/systemd': '../.dotfiles/systemd/.config/systemd', HOME / '.config/quickshell': '../.dotfiles/quickshell/.config/quickshell', } MAX_FILE = 1024 * 1024 def validate_aliases(): # CONTRACT: Stow reste propriétaire ; vérifier les liens littéraux puis écrire # aux seuls chemins canoniques, sans traversée de symlink pendant publication. for path, link in ALIASES.items(): parents(path) info = path.lstat() if not stat.S_ISLNK(info.st_mode) or info.st_uid != UID or os.readlink(path) != link: raise ValueError('Lien Stow divergent : ' + str(path)) def digest(data): return hashlib.sha256(data).hexdigest() def parents(path): """INVARIANT: aucun ancêtre symlink ; /var/tmp sticky root est l'unique exception.""" for parent in path.parents: if not os.path.lexists(parent): if parent == WANTS: continue raise ValueError('Parent absent : ' + str(parent)) info = parent.lstat() shared = parent == Path('/var/tmp') and info.st_uid == 0 and info.st_mode & stat.S_ISVTX if (not stat.S_ISDIR(info.st_mode) or info.st_uid not in (0, UID) or (info.st_mode & 0o022 and not shared)): raise ValueError('Parent non sûr : ' + str(parent)) def read(path, mode=None): parents(path) fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) with os.fdopen(fd, 'rb') as stream: info = os.fstat(stream.fileno()) if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_uid != UID or info.st_gid != UID or info.st_mode & 0o022 or info.st_size > MAX_FILE or (mode is not None and stat.S_IMODE(info.st_mode) != mode)): raise ValueError('Fichier non sûr : ' + str(path)) data = stream.read(MAX_FILE + 1) if len(data) > MAX_FILE: raise ValueError('Fichier trop grand') return data def fsync_dir(path): fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) try: os.fsync(fd) finally: os.close(fd) def write(path, data, mode=0o600): parents(path) fd, name = tempfile.mkstemp(prefix='.labfy-v2-', dir=path.parent) try: with os.fdopen(fd, 'wb') as stream: stream.write(data) stream.flush() os.fchmod(stream.fileno(), mode) os.fsync(stream.fileno()) os.replace(name, path) fsync_dir(path.parent) finally: if os.path.lexists(name): os.unlink(name) def snapshot(path, mode): parents(path) if not os.path.lexists(path): return {'kind': 'absent'} if mode is None: info = path.lstat() if not stat.S_ISLNK(info.st_mode) or info.st_uid != UID or os.readlink(path) != LINK: raise ValueError('Activation divergente : ' + str(path)) return {'kind': 'symlink', 'link': LINK} return {'kind': 'file', 'sha256': digest(read(path, mode)), 'mode': mode} def prepare(bundle): validate_aliases() parents(bundle) if os.path.lexists(WANTS): raise ValueError('Répertoire wants déjà présent ; nouvelle revue requise') bundle.mkdir(mode=0o700) manifest = {'version': 2, 'files': {}} for key, (path, mode, source) in TARGETS.items(): before = snapshot(path, mode) if key in ('session', 'manifest', 'qml') and before['kind'] != 'file': raise ValueError('Original requis absent : ' + key) if key in ('guard', 'service', 'wants') and before['kind'] != 'absent': raise ValueError('Nouvelle cible déjà présente : ' + key) if before['kind'] == 'file': write(bundle / (key + '.old'), read(path, mode)) if source: data = read(SOURCE / source) write(bundle / (key + '.new'), data) after = {'kind': 'file', 'sha256': digest(data), 'mode': mode} else: after = {'kind': 'symlink', 'link': LINK} manifest['files'][key] = {'old': before, 'new': after} write(bundle / 'manifest.json', (json.dumps(manifest, indent=2) + '\n').encode()) write(bundle / 'mosaic-user-update.py', Path(__file__).read_bytes()) print('Bundle utilisateur préparé, aucune cible modifiée :', bundle) def load(bundle): manifest = json.loads(read(bundle / 'manifest.json', 0o600)) if set(manifest) != {'version', 'files'} or manifest['version'] != 2 or set(manifest['files']) != set(TARGETS): raise ValueError('Manifeste hors périmètre') contents = {} for key, states in manifest['files'].items(): if set(states) != {'old', 'new'}: raise ValueError('États invalides') contents[key] = {} for kind, record in states.items(): expected_kind = 'symlink' if key == 'wants' else 'file' if kind == 'old' and key in ('guard', 'service', 'wants'): expected_kind = 'absent' if not isinstance(record, dict) or record.get('kind') != expected_kind: raise ValueError('Type de cible invalide') if record['kind'] == 'file': if set(record) != {'kind', 'sha256', 'mode'} or record['mode'] != TARGETS[key][1]: raise ValueError('Mode invalide') data = read(bundle / (key + '.' + kind), 0o600) if digest(data) != record['sha256']: raise ValueError('SHA invalide : ' + key) contents[key][kind] = data elif record != ({'kind': 'symlink', 'link': LINK} if record['kind'] == 'symlink' else {'kind': 'absent'}): raise ValueError('Lien/absence invalide') return manifest, contents def check(bundle): validate_aliases() manifest, contents = load(bundle) current = {} for key, (path, mode, _) in TARGETS.items(): row = snapshot(path, mode) if row not in manifest['files'][key].values(): raise ValueError('Destination divergente : ' + str(path)) current[key] = row return manifest, contents, current def journal(bundle, state): write(bundle / 'state', (state + '\n').encode()) def publish(bundle, manifest, contents, kind): # WHY: le lien wants paraît en dernier ; aucun rechargement/activation implicite. # Le journal durable précède chaque lot ; la reprise accepte old/new uniquement. for key, (path, mode, _) in TARGETS.items(): wanted = manifest['files'][key][kind] if snapshot(path, mode) == wanted: continue if wanted['kind'] == 'absent': path.unlink() fsync_dir(path.parent) elif wanted['kind'] == 'file': write(path, contents[key][kind], mode) else: if not WANTS.exists(): WANTS.mkdir(mode=0o755) fsync_dir(WANTS.parent) temporary = path.with_name('.labfy-v2-' + os.urandom(12).hex()) try: temporary.symlink_to(LINK) os.replace(temporary, path) fsync_dir(path.parent) finally: if os.path.lexists(temporary): temporary.unlink() for key, (path, mode, _) in TARGETS.items(): if snapshot(path, mode) != manifest['files'][key][kind]: raise ValueError('Publication non confirmée') if kind == 'old' and WANTS.exists(): try: WANTS.rmdir() fsync_dir(WANTS.parent) except OSError: pass # Un autre lien n'appartient pas à cette transaction. def durable_bundle(bundle, manifest=None): # WHY: conserver le secours autonome et les entrées des nouveaux répertoires # est nécessaire même si la copie du bundle n'a jamais été synchronisée. # CONTRACT: barrière complète avant journal/publication, sans rollback si échec. if manifest is None: manifest, _ = load(bundle) names = ['manifest.json', 'mosaic-user-update.py'] names += [key + '.' + kind for key, states in manifest['files'].items() for kind, record in states.items() if record['kind'] == 'file'] if (bundle / 'state').exists(): names.append('state') parents(bundle / 'manifest.json') for name in names: fd = os.open(bundle / name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) try: info = os.fstat(fd) if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_uid != UID or info.st_gid != UID or stat.S_IMODE(info.st_mode) != 0o600 or info.st_size > MAX_FILE): raise ValueError('Fichier de secours non sûr : ' + str(bundle / name)) os.fsync(fd) finally: os.close(fd) # INVARIANT: synchroniser les contenus avant leurs entrées, jusqu'à /. for directory in (bundle, *bundle.parents): fsync_dir(directory) def transact(bundle, operation): manifest, contents, current = check(bundle) state = read(bundle / 'state', 0o600).decode().strip() if (bundle / 'state').exists() else 'original' if state not in ('original', 'applying', 'applied', 'rolling-back'): raise ValueError('Journal invalide') durable_bundle(bundle, manifest) if operation == 'apply' and state in ('applying', 'rolling-back'): journal(bundle, 'rolling-back') publish(bundle, manifest, contents, 'old') journal(bundle, 'original') raise ValueError('Interruption restaurée ; vérifier puis relancer explicitement') kind = 'new' if operation == 'apply' else 'old' if operation == 'apply' and state == 'original' and any(current[k] != manifest['files'][k]['old'] for k in TARGETS): raise ValueError('État mixte sans journal ; rollback requis') journal(bundle, 'applying' if kind == 'new' else 'rolling-back') try: publish(bundle, manifest, contents, kind) journal(bundle, 'applied' if kind == 'new' else 'original') except BaseException: journal(bundle, 'rolling-back') check(bundle) publish(bundle, manifest, contents, 'old') journal(bundle, 'original') raise print('Utilisateur :', operation, 'vérifié ; aucun daemon-reload/enable/start') def main(): p = argparse.ArgumentParser(description=__doc__) p.add_argument('operation', choices=('prepare', 'check', 'durable-barrier', 'apply', 'rollback')) p.add_argument('bundle', type=Path) args = p.parse_args() if os.getuid() != UID or os.geteuid() != UID: p.error('Compte utilisateur attendu ; jamais root') bundle = args.bundle.absolute() if args.operation == 'prepare': prepare(bundle) return if args.operation == 'check': check(bundle) print('Préflight utilisateur PASS ; lecture seule') return # Le secours doit survivre au nettoyage de /var/tmp et ne pas importer session.py. if not bundle.is_relative_to(STATE): p.error('Bundle durable sous ' + str(STATE) + ' requis après approbation') parents(bundle / 'state') info = bundle.lstat() if not stat.S_ISDIR(info.st_mode) or info.st_uid != UID or stat.S_IMODE(info.st_mode) != 0o700: p.error('Bundle privé 0700 requis') fd = os.open(STATE / 'update.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600) try: info = os.fstat(fd) if not stat.S_ISREG(info.st_mode) or info.st_uid != UID or info.st_nlink != 1 or stat.S_IMODE(info.st_mode) != 0o600: raise ValueError('Verrou invalide') fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) if args.operation == 'durable-barrier': manifest, _, _ = check(bundle) durable_bundle(bundle, manifest) else: transact(bundle, args.operation) finally: os.close(fd) if __name__ == '__main__': try: main() except (OSError, ValueError) as error: raise SystemExit('Arrêt sans activation : ' + str(error))