#!/usr/bin/env python3 """Synthetic idmapped data views inside owned cgroups/private mount namespaces.""" import errno import json import os from pathlib import Path import socket import sys import time import traceback from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'scripts')) from c01_children import Children, drop_credentials, process_group_path from c01_views import PrivateMounts, PinnedSource, identity, pin_directory, require_private_backing, swap_map import c01_views assert os.geteuid() == 0 and os.environ.get('ARCHY_C01_VIEWS_FIXTURE') == '1' assert Path(process_group_path(os.getpid())).parent.name.startswith('opencode-c01-views-') for directory in ('/run', '/var/lib/archipelago'): rows = [line.split() for line in Path('/proc/self/mountinfo').read_text().splitlines()] assert any(row[4] == directory and row[row.index('-') + 1] == 'tmpfs' for row in rows) OLD, MANAGER, RUNTIME = 1000, 60001, 60002 root = Path('/run/c01-view-fixture'); root.mkdir(mode=0o700) backing = root / 'backing'; backing.mkdir(mode=0o700); os.chown(backing, OLD, OLD) private = backing / 'identity'; private.mkdir(mode=0o700); os.chown(private, OLD, OLD) key = private / 'key'; key.write_bytes(b'synthetic-private-identity'); key.chmod(0o600); os.chown(key, OLD, OLD) work = root / 'work'; work.mkdir(mode=0o700); os.chown(work, OLD, OLD) workfile = work / 'file'; workfile.write_bytes(b'synthetic-work'); workfile.chmod(0o600); os.chown(workfile, OLD, OLD) data_fd = require_private_backing(backing) work_fd = pin_directory(work) race = root / 'race'; race.mkdir(mode=0o700); os.chown(race, OLD, OLD) (race / 'original').write_bytes(b'pinned-source') race_fd = pin_directory(race) removed = root / 'removed'; removed.mkdir(mode=0o700); os.chown(removed, OLD, OLD) removed_fd = pin_directory(removed) data_source = PinnedSource.capture(data_fd, backing) work_source = PinnedSource.capture(work_fd, work) race_source = PinnedSource.capture(race_fd, race) removed_source = PinnedSource.capture(removed_fd, removed) before = {name: identity(fd) for name, fd in (('data', data_fd), ('work', work_fd))} key_before = (key.stat().st_ino, key.stat().st_uid, key.stat().st_gid, key.stat().st_mode) work_flags = os.statvfs(work).f_flag namespace = os.stat('/proc/self/ns/mnt').st_ino targets = Path('/var/lib/archipelago') for name in ('manager', 'runtime-work', 'manager-work', 'readonly-work', 'covered', 'pinned-race', 'refused-replacement'): (targets / name).mkdir(mode=0o755) (targets / 'covered/original').write_bytes(b'original') os.symlink(targets / 'manager/identity/key', work / 'private-escape') def denied(path): try: path.read_bytes() except PermissionError: return raise AssertionError('runtime reached private identity') def as_user(uid, action): parent, child = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET) pid = os.fork() if pid == 0: try: parent.close() drop_credentials(uid, uid, os.getppid()) action() child.send(b'passed') os._exit(0) except BaseException: traceback.print_exc() os._exit(111) child.close(); parent.settimeout(3) try: assert parent.recv(16) == b'passed' assert os.waitpid(pid, 0)[1] == 0 finally: parent.close() owner = Children() try: def prepare_views(control): view = PrivateMounts() view.cover(targets / 'covered') assert not (targets / 'covered/original').exists() (targets / 'covered/new').write_bytes(b'private-namespace') view.idmapped_directory(data_source, targets / 'manager', OLD, MANAGER) view.idmapped_directory(work_source, targets / 'runtime-work', OLD, RUNTIME) view.idmapped_directory(work_source, targets / 'manager-work', OLD, MANAGER) view.idmapped_directory(work_source, targets / 'readonly-work', OLD, RUNTIME, readonly=True) race.rename(root / 'original-race') race.mkdir(mode=0o700); (race / 'bait').write_bytes(b'replacement') try: view.idmapped_directory(race_source, targets / 'pinned-race', OLD, MANAGER) except ValueError: pass else: raise AssertionError('renamed source or replacement accepted under stale locator') assert not (targets / 'pinned-race/original').exists() assert not (targets / 'pinned-race/bait').exists() removed.rmdir(); removed.mkdir(mode=0o700) try: view.idmapped_directory(removed_source, targets / 'refused-replacement', OLD, MANAGER) except (FileNotFoundError, ValueError): pass else: raise AssertionError('unlinked source silently adopted its replacement') assert (targets / 'refused-replacement').stat().st_uid == 0 def runtime(): assert (targets / 'runtime-work/file').read_bytes() == b'synthetic-work' denied(targets / 'manager/identity/key') denied(targets / 'runtime-work/private-escape') denied(key) (targets / 'runtime-work/from-runtime').write_bytes(b'runtime') try: (targets / 'readonly-work/refused').write_bytes(b'no') except OSError as error: assert error.errno == errno.EROFS else: raise AssertionError('read-only view allowed write') try: view.cover(targets / 'manager') except ValueError: pass else: raise AssertionError('view setup permitted after credential drop') code = ('import os,pathlib;assert os.geteuid()==0;' 'assert pathlib.Path("/var/lib/archipelago/runtime-work/file").read_bytes()==b"synthetic-work"\n' 'try:pathlib.Path("/var/lib/archipelago/manager/identity/key").read_bytes()\n' 'except PermissionError:print("USERNS_PRIVATE_DENIED")\n' 'else:raise AssertionError("namespace root read private key")') nested = c01_views.subprocess.run(['/usr/bin/unshare', '--user', '--map-root-user', '/usr/bin/python3', '-I', '-c', code], capture_output=True, timeout=5, env={'PATH': '/usr/bin:/bin', 'LC_ALL': 'C'}) assert nested.returncode == 0 and nested.stdout.strip() == b'USERNS_PRIVATE_DENIED' as_user(RUNTIME, runtime) def manager(): assert (targets / 'manager/identity/key').read_bytes() == b'synthetic-private-identity' assert (targets / 'manager-work/from-runtime').read_bytes() == b'runtime' (targets / 'manager-work/from-manager').write_bytes(b'manager') as_user(MANAGER, manager) assert identity(data_fd) == before['data'] and identity(work_fd) == before['work'] control.send(b'views-passed') def prepare(control): original = c01_views.subprocess.run def observed_run(*args, **kwargs): if os.environ.get('ARCHY_C01_VIEW_DIAGNOSTICS') == '1': kwargs['env'] = {**kwargs['env'], 'LIBMOUNT_DEBUG': 'all'} result = original(*args, **kwargs) if result.returncode: print(result.stderr.decode(errors='replace'), file=sys.stderr, flush=True) return result try: with patch.object(c01_views.subprocess, 'run', observed_run): prepare_views(control) except BaseException: traceback.print_exc() raise record = owner.spawn('application', prepare, keep=(data_fd, work_fd, race_fd, removed_fd)) record.channel.settimeout(15); assert record.channel.recv(32) == b'views-passed' deadline = time.monotonic() + 5 while owner.children: assert owner.poll() == [] assert time.monotonic() < deadline time.sleep(0.005) assert owner.results['application'] == 0 owner.close() assert os.stat('/proc/self/ns/mnt').st_ino == namespace assert (targets / 'covered/original').read_bytes() == b'original' assert not (targets / 'covered/new').exists() assert (targets / 'manager').stat().st_uid == 0 assert identity(data_fd) == before['data'] and identity(work_fd) == before['work'] assert (key.stat().st_ino, key.stat().st_uid, key.stat().st_gid, key.stat().st_mode) == key_before assert key.read_bytes() == b'synthetic-private-identity' and workfile.read_bytes() == b'synthetic-work' assert os.statvfs(work).f_flag == work_flags assert (work / 'from-runtime').stat().st_uid == OLD and (work / 'from-manager').stat().st_uid == OLD assert not (work / 'refused').exists() assert swap_map(MANAGER, OLD) == swap_map(OLD, MANAGER) print(json.dumps({'passed':True,'private_identity_separated':True,'shared_work_preserves_host_owner':True, 'read_only_enforced':True,'parent_namespace_and_mount_flags_unchanged':True, 'existing_bytes_modes_owners_inodes_preserved':True,'runtime_user_namespace_root_private_read_denied':True, 'renamed_source_locator_refused':True,'unlinked_source_replacement_refused':True,'installed_acceptance':False})) finally: owner.request_stop() deadline = time.monotonic() + 5 while owner.children: owner.poll() assert time.monotonic() < deadline, 'view fixture retained a child' time.sleep(0.005) for fd in (data_fd, work_fd, race_fd, removed_fd): os.close(fd)