"""Real Debian frontend/inner locks; no boolean exclusion attestations.""" import contextlib import fcntl import os import pathlib from d09_store import secure from os_update_plan import require LOCKS = (pathlib.Path('/var/lib/dpkg/lock-frontend'), pathlib.Path('/var/lib/dpkg/lock')) def evidence(paths=LOCKS): rows = pathlib.Path('/proc/locks').read_text().splitlines() result = [] for path in paths: st = path.stat() key = '%02x:%02x:%d' % (os.major(st.st_dev), os.minor(st.st_dev), st.st_ino) matches = [line.split() for line in rows if key in line.split()] require(any(row[1:5] == ['POSIX', 'ADVISORY', 'WRITE', str(os.getpid())] for row in matches), 'Debian lock not owned by current process: ' + str(path)) result.append(dict(path=str(path), device=st.st_dev, inode=st.st_ino, pid=os.getpid())) return result @contextlib.contextmanager def probe(paths=LOCKS): """Admission probe only. Execution must reacquire and retain its own lease.""" descriptors = [] try: for path in paths: fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o640) descriptors.append(fd) os.close(secure(path)) fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) yield evidence(paths) finally: for fd in reversed(descriptors): os.close(fd) class PackageLease: """libapt owns frontend continuously; dpkg gets only the inner-lock handoff. Same-process libapt avoids an unsafe unlock/relock gap before apt-get. Never use Debug::NoLocking or release frontend to accommodate a subprocess. """ def __init__(self, config): self.config = config def __enter__(self): import apt import apt_pkg self.apt, self.apt_pkg = apt, apt_pkg self.previous = os.environ.get('APT_CONFIG') os.environ['APT_CONFIG'] = str(self.config) # python3-apt clear requires a key; clearing every existing subtree also # removes any host-loaded hook/source children from apt's import-time init. for key in list(apt_pkg.config.keys()): apt_pkg.config.clear(key) apt_pkg.init_config() apt_pkg.init_system() self.lock = apt_pkg.SystemLock() self.lock.__enter__() try: self.observation = evidence() self.cache = apt.Cache() self.cache.upgrade(dist_upgrade=True) return self except BaseException: self.__exit__(None, None, None) raise def plan(self, expected): changes = set() for package in self.cache.get_changes(): if package.marked_delete: changes.add(('Remv', package.name.split(':')[0], package.installed.version)) else: changes.add(('Inst', package.name.split(':')[0], package.candidate.version)) wanted = {(p['operation'], p['name'].split(':')[0], p['version']) for p in expected if p['operation'] != 'Conf'} require(changes == wanted, 'locked libapt transaction differs from authenticated plan') self.pm = self.apt_pkg.PackageManager(self.cache._depcache) self.fetcher = self.apt_pkg.Acquire(self.apt.progress.base.AcquireProgress()) # Offline file mirror, authenticated with Debian keyring; no install yet. self.cache._fetch_archives(self.fetcher, self.pm, False) evidence() def install(self): import apt.progress.base os.environ.update(DEBIAN_FRONTEND='noninteractive', NEEDRESTART_MODE='l', PATH='/usr/sbin:/usr/bin:/sbin:/bin', LC_ALL='C') result = self.cache.install_archives(self.pm, apt.progress.base.InstallProgress()) # No media/retry loop: partial or unknown outcome always reconciles. require(result == self.pm.RESULT_COMPLETED, 'dpkg partial/unknown result') evidence() def __exit__(self, *exc): try: self.lock.__exit__(*exc) finally: if self.previous is None: os.environ.pop('APT_CONFIG', None) else: os.environ['APT_CONFIG'] = self.previous