Files
archy/tests/regression/os-custody-coordinator.py

71 lines
3.9 KiB
Python

#!/usr/bin/python3
"""Real mount/UID/process recovery test in an explicitly private namespace."""
import ctypes
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
ROOT=Path(__file__).resolve().parents[2]
sys.path.insert(0,str(ROOT/'scripts'))
import os_update_mount_custody as coordinator
from d09_store import atomic
assert os.geteuid()==0 and os.readlink('/proc/self/ns/mnt')!=sys.argv[1]
libc=ctypes.CDLL(None,use_errno=True)
def mount_tmp(path):
assert libc.mount(b'tmpfs',os.fsencode(path),b'tmpfs',2|4,b'size=64m,mode=0755')==0
mount_tmp('/var/lib');mount_tmp('/etc')
data=Path('/var/lib/archipelago');data.mkdir();mount_tmp(data);os.chown(data,1000,1000)
support=data/'support';support.mkdir(mode=0o755)
old=support/'previous-evidence';old.write_bytes(b'retained old evidence\n')
oldmeta=old.stat();before=data.stat()
fd=os.open(support,os.O_PATH|os.O_DIRECTORY)
expected=coordinator.identity(fd);os.close(fd)
value={'version':1,'node':os.uname().nodename,'source':str(support),'source_identity':expected,
'alias':'/var/lib/archipelago-delivery-support','os_root':'/var/lib/archipelago-os-update',
'stage_name':'delivery-os-custody'}
atomic(coordinator.CONFIG,json.dumps(value,sort_keys=True).encode())
entry=str(ROOT/'scripts/os_update_mount_custody.py')
def recover():
result=subprocess.run(['/usr/bin/python3','-B',entry,'recover'],capture_output=True,text=True,timeout=20)
assert result.returncode==0,result.stderr
return json.loads(result.stdout)
first=recover()
root=Path(value['os_root']);sentinel=root/'journal-sentinel'
sentinel.write_bytes(b'never reset existing updater journal\n');sentinel.chmod(0o600)
retained=(sentinel.stat().st_dev,sentinel.stat().st_ino,hashlib.sha256(sentinel.read_bytes()).hexdigest())
second=recover();assert second['stage_identity']==first['stage_identity'] and second['sequence']>first['sequence']
# Simulate loss of volatile mount state while retaining both filesystems and
# durable OS coordinator journal. Recovery runs in a new process, not a reused FD.
assert libc.umount2(os.fsencode(root),0)==0
assert libc.umount2(os.fsencode(value['alias']),0)==0
third=recover();assert third['stage_identity']==first['stage_identity']
assert (sentinel.stat().st_dev,sentinel.stat().st_ino,hashlib.sha256(sentinel.read_bytes()).hexdigest())==retained
assert (data.stat().st_dev,data.stat().st_ino,data.stat().st_uid,data.stat().st_gid)==(before.st_dev,before.st_ino,1000,1000)
pid=os.fork()
if pid==0:
try:
os.setgroups([]);os.setgid(1000);os.setuid(1000)
(data/'runtime-write-control').write_text('allowed')
for path in (root/'hostile',Path(value['alias'])/'hostile',coordinator.STATE/'hostile'):
try:path.write_text('forbidden')
except PermissionError:pass
else:os._exit(10)
support.rename(data/'support-retained');support.mkdir();(support/'hostile').write_text('untrusted')
os._exit(0)
except BaseException:os._exit(11)
assert os.waitpid(pid,0)[1]==0,'real UID1000 negative failed'
failed=subprocess.run(['/usr/bin/python3','-B',entry,'recover'],capture_output=True,text=True,timeout=20)
assert failed.returncode!=0 and 'original support inode/custody differs' in failed.stderr
assert not (support/'delivery-os-custody').exists()
assert (Path(value['alias'])/'previous-evidence').stat().st_ino==oldmeta.st_ino
assert (Path(value['alias'])/'previous-evidence').read_bytes()==b'retained old evidence\n'
assert (sentinel.stat().st_dev,sentinel.stat().st_ino,hashlib.sha256(sentinel.read_bytes()).hexdigest())==retained
print(json.dumps({'passed':True,'first':first,'recovered':third,'actual_uid1000_attack_refused':True,
'source_path_swap_refused':True,'existing_evidence_preserved':True,'data_root_ownership_preserved':True,
'new_process_mount_recovery':True,'existing_OS_journal_preserved':True,'physical_power_operation':False,
'scope':'private mount namespace; durable process/mount recovery, not installed-node acceptance'},sort_keys=True))