Files

275 lines
13 KiB
Python

#!/usr/bin/env python3
"""Disposable process integration; no installed state or trusted-time claim."""
import hashlib
import json
import os
from pathlib import Path
import shutil
import signal
import socket
import subprocess
import sys
import time
import traceback
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'scripts'))
import c01_supervisor as supervisor
import c01_host_watch as watch
import c01_views as views
from c01_fixture_process import run_bounded
assert os.geteuid() == 0 and os.environ.get('ARCHY_C01_SUPERVISOR_FIXTURE') == '1'
assert sys.argv[1:] in ([], ['--python-only'])
assert Path(supervisor.lifecycle.process_group_path(os.getpid())).parent.name.startswith('opencode-c01-supervisor-')
for directory in ('/run', '/var/lib/archipelago', '/var/lib/archipelago-c01', '/etc/archipelago-c01'):
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), directory
root = Path('/var/lib/archipelago-c01/fixture')
root.mkdir(mode=0o700)
def refused(body, text=None):
try:
body()
except (OSError, ValueError) as error:
if text: assert text in str(error), str(error)
else:
raise AssertionError('unexpected admission')
def owner(name):
return supervisor.Supervisor(supervisor.LaunchLock(root / (name + '.lock')))
def await_status(record, wanted):
record.channel.settimeout(5)
assert record.channel.recv(128) == wanted
def done(record):
print('PASS ' + record, flush=True)
refused(lambda: supervisor.admit({'old_writers_excluded': True, 'migration_complete': True}, None, None),
'C01_ADMISSION_UNAVAILABLE')
done('persisted-exclusion-booleans-cannot-admit')
assert supervisor.runtime_workspace_environment({'home': '/home/preserved-runtime'}) == {
'ARCHIPELAGO_RUNTIME_WORKSPACE': '/home/preserved-runtime/Work'}
assert supervisor.BASE / 'manager' != Path('/home/preserved-runtime/Work')
done('runtime-workspace-contract-is-independent-of-private-manager-home')
key = root / 'key'
key.write_bytes(bytes(range(1, 33)))
key.chmod(0o600)
fd = supervisor.root_capability(key, 32)
identity = supervisor.key_binding(fd)
assert os.lseek(fd, 0, os.SEEK_CUR) == 0
os.link(key, root / 'key-alias')
refused(lambda: supervisor.root_capability(key, 32), 'invalid root capability')
(root / 'key-alias').unlink()
done('key-capability-no-parent-read-and-hardlink-denial')
managed = root / 'manager'; managed.mkdir(mode=0o700)
directory = views.pin_directory(managed)
identity_dir = managed / 'identity'; identity_dir.mkdir(mode=0o700)
secret = identity_dir / 'nostr_secret'; secret.write_bytes(b'synthetic-identity'); secret.chmod(0o600)
assert supervisor.private_identity(directory, 0) == (secret.stat().st_dev, secret.stat().st_ino)
os.link(secret, root / 'secret-alias')
refused(lambda: supervisor.private_identity(directory, 0), 'secret/alias')
(root / 'secret-alias').unlink()
identity_dir.rename(managed / 'original')
identity_dir.symlink_to(managed / 'original')
refused(lambda: supervisor.private_identity(directory, 0))
os.close(directory)
done('identity-symlink-and-external-hardlink-denied')
owned = owner('keyless')
try:
owned.children.reserve('administrator')
reservation = owned.children.reserved['administrator']
reserved_inode = reservation[0].path.stat().st_ino
refused(lambda: owned.children.reserve('administrator'), 'role already')
def child(control):
for entry in Path('/proc/self/fd').iterdir():
try: meta = os.fstat(int(entry.name))
except OSError: continue
assert (meta.st_dev, meta.st_ino) != (identity['device'], identity['inode'])
supervisor.lifecycle.drop_credentials(60002, 60002, os.getppid())
control.send(b'KEYLESS')
supervisor.child_wait(control, b'RELEASE', supervisor.now() + 5_000_000_000)
record = owned.children.spawn('administrator', child)
assert not owned.children.reserved and record.group.path.stat().st_ino == reserved_inode
time.sleep(0.2) # Force queued status to survive delayed parent scheduling.
owned.wait(record, b'KEYLESS', 60002, 60002, supervisor.now() + 5_000_000_000)
assert os.lseek(fd, 0, os.SEEK_CUR) == 0
supervisor.packet(record.channel, b'RELEASE')
end = time.monotonic() + 3
while 'administrator' not in owned.children.results and time.monotonic() < end:
owned.tick(); time.sleep(0.005)
assert owned.children.results.get('administrator') == 0
finally:
owned.retire()
os.close(fd)
done('late-administrator-inherits-no-storage-key-fds')
owned = owner('unused-reservation')
owned.children.reserve('administrator')
reserved_path = owned.children.reserved['administrator'][0].path
owned.retire()
assert not reserved_path.exists()
done('unused-precollection-admin-reservation-retired')
owned = owner('retained')
try:
record = owned.children.spawn('host-io', lambda control: time.sleep(60))
original_empty = record.group.empty
record.group.empty = lambda: False # injected kernel-unretired observation
owned.stop('injected stall')
for _ in range(10):
owned.tick(); time.sleep(0.01)
assert 'host-io' in owned.children.children
refused(lambda: supervisor.LaunchLock(root / 'retained.lock'))
for _ in range(5): os.kill(os.getpid(), signal.SIGTERM)
assert owned.children.stopping
record.group.empty = original_empty
finally:
owned.retire()
reacquired = supervisor.LaunchLock(root / 'retained.lock'); reacquired.close()
done('stall-retains-lock-and-repeated-stop-remains-responsive')
# A new flock owner must not mistake the previous root process's crash for
# completed role retirement, including a different delegated-unit path.
owned = owner('crash-locator')
try:
record = owned.children.spawn('host-io', lambda control: time.sleep(60))
other = supervisor.LaunchLock(root / 'second.lock')
locator = json.dumps({'boot': supervisor.recovery.boot_id(), 'group': str(owned.children.root)}).encode()
os.write(other.fd, locator)
refused(lambda: other.bind_group(owned.children.root), 'retirement remains unresolved')
other.close()
finally:
owned.retire()
done('crash-locator-rejects-prior-unretired-role')
owned = owner('host-watch')
client, accepted = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
try:
def worker(control):
control.send(watch.PACKET.pack(1, watch.READY, 0, 0))
control.send(watch.PACKET.pack(1, watch.STARTED, 1, watch.now() + 300_000_000))
time.sleep(60)
record = owned.children.spawn('host-io', worker, keep=(accepted.fileno(),))
owned.watch = watch.HostWatch(record.channel, accepted, record.pid, watch.now() + 1_000_000_000)
end = time.monotonic() + 3
while owned.tick() and time.monotonic() < end: time.sleep(0.01)
assert owned.children.stopping and owned.watch.failure == 'host worker deadline expired'
client.settimeout(1); assert client.recv(1) == b''
finally:
owned.retire(); client.close()
done('host-progress-stall-revokes-actual-application-endpoint')
owned = owner('forged-status')
try:
record = owned.children.spawn('authority', lambda control: (control.send(b'STORE_READY'), time.sleep(60)))
refused(lambda: owned.wait(record, b'STORE_READY', 60002, 60002,
supervisor.now() + 5_000_000_000), 'status refused')
finally:
owned.retire()
done('setup-status-requires-actual-role-credentials')
if sys.argv[1:] == ['--python-only']:
assert not Path(f'/proc/self/task/{os.getpid()}/children').read_text().strip()
print('Python-only fixture boundary: native embedding/proxy cases NOT executed', flush=True)
raise SystemExit(0)
# Compose the launcher's actual mount preparation with its lifecycle owner,
# using preserved tmpfs backing inodes, not a mocked view implementation.
prepared = supervisor.Prepared.__new__(supervisor.Prepared)
prepared.fds = []
prepared.value = {'runtime_uid': 1000, 'runtime_gid': 1000, 'runtime_data_dir': '/var/lib/archipelago'}
prepared.backings = {}
prepared.backing_sources = {}
for name in ('manager', 'snapshot', 'authority'):
path = root / ('backing-' + name); path.mkdir(mode=0o700); os.chown(path, 1000, 1000)
(path / 'record').write_bytes(name.encode()); (path / 'record').chmod(0o600); os.chown(path / 'record', 1000, 1000)
prepared.backings[name] = prepared.own(views.require_private_backing(path))
prepared.backing_sources[name] = views.PinnedSource.capture(prepared.backings[name], path)
runtime = Path('/var/lib/archipelago'); runtime.chmod(0o700); os.chown(runtime, 1000, 1000)
(runtime / 'wallet-fixture').write_bytes(b'preserved'); (runtime / 'wallet-fixture').chmod(0o600)
os.chown(runtime / 'wallet-fixture', 1000, 1000)
prepared.runtime = prepared.own(os.open(runtime, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC))
prepared.runtime_source = views.PinnedSource.capture(prepared.runtime, runtime)
proxy = root / 'proxy'
with proxy.open('xb') as output:
subprocess.run(['/opt/fixture/native', 'export-runtime-proxy'], stdout=output, check=True, timeout=5)
proxy.chmod(0o555)
prepared.proxy = prepared.own(os.open(proxy, os.O_PATH | os.O_CLOEXEC))
prepared.proxy_source = views.PinnedSource.capture(prepared.proxy, proxy)
original = {name: views.identity(fd) for name, fd in prepared.backings.items()}
old_namespace = os.stat('/proc/self/ns/mnt').st_ino
host_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
host_socket.bind('/run/forbidden-host-bus')
owned = owner('views')
try:
# Capture proxy metadata before drop; the protected source path is denied.
proxy_inode = proxy.stat().st_ino
def mapped(control):
try:
prepared.role_views(SimpleNamespace(pw_uid=60001, pw_gid=60001), 'application')
supervisor.close_except({control.fileno()})
supervisor.lifecycle.drop_credentials(60001, 60001, os.getppid())
assert (supervisor.BASE / 'manager/record').read_bytes() == b'manager'
assert (runtime / 'wallet-fixture').read_bytes() == b'preserved'
assert not Path('/run/forbidden-host-bus').exists()
assert (supervisor.BASE / 'bin/podman').stat().st_ino == proxy_inode
refused(lambda: (root / 'backing-authority/record').read_bytes())
refused(lambda: (supervisor.BASE / 'bin/podman').open('wb'))
control.send(b'VIEWS_READY')
time.sleep(60)
except BaseException:
traceback.print_exc(); raise
record = owned.children.spawn('application', mapped, keep=prepared.fds)
owned.wait(record, b'VIEWS_READY', 60001, 60001, supervisor.now() + 15_000_000_000)
finally:
owned.retire(); host_socket.close()
assert os.stat('/proc/self/ns/mnt').st_ino == old_namespace
assert all(views.identity(fd) == original[name] for name, fd in prepared.backings.items())
assert (runtime / 'wallet-fixture').stat().st_uid == 1000
prepared.close()
done('composed-private-views-proxy-inode-aliases-and-runtime-preservation')
# Use the real embedded payload for both identity and hostile import checks.
binary = root / 'native'; shutil.copyfile('/opt/fixture/native', binary); binary.chmod(0o755)
poison = root / 'poison'; poison.mkdir()
marker = root / 'imported-untrusted-code'
for name in ('sitecustomize', 'c01_supervisor', 'ctypes', 'json'):
(poison / (name + '.py')).write_text(f'open({str(marker)!r},"w").write("bad")\n')
env = {**os.environ, 'PYTHONPATH': str(poison), 'PYTHONHOME': str(poison), 'PYTHONSTARTUP': str(poison / 'c01_supervisor.py')}
seen_native = False
def sample_native(pid):
global seen_native
try:
actual = Path(f'/proc/{pid}/exe').stat()
expected = binary.stat()
seen_native |= (actual.st_dev, actual.st_ino) == (expected.st_dev, expected.st_ino)
except FileNotFoundError:
pass
result = run_bounded([str(binary), 'c01-supervisor-v3'], cwd=poison, env=env,
seconds=5, observe=sample_native)
output, errors = result.stdout, result.stderr
assert result.returncode != 0 and seen_native and not marker.exists(), errors.decode()
assert b'/etc/archipelago-c01/launch.json' in errors, errors.decode()
assert not output
done('native-mainpid-isolated-embedding-ignores-env-site-and-cwd')
extra = run_bounded([str(binary), 'c01-supervisor-v3', str(poison / 'c01_supervisor.py')], seconds=5)
assert extra.returncode != 0 and b'accepts no arguments' in extra.stderr and not marker.exists()
done('native-supervisor-rejects-arbitrary-root-code-arguments')
assert not Path(f'/proc/self/task/{os.getpid()}/children').read_text().strip()
assert not any((Path('/sys/fs/cgroup') / supervisor.lifecycle.process_group_path(os.getpid()).lstrip('/')).parent.glob('c01-*'))
done('all-owned-children-and-role-cgroups-retired')