Files
archy/tests/regression/c01-views.py
T

198 lines
9.3 KiB
Python

#!/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)