Files
archy/scripts/tests/test_c01_runtime.py

223 lines
11 KiB
Python

"""Real descriptor framing regressions; service/kernel composition is separate."""
import array
import configparser
import io
import os
import resource
from pathlib import Path
import socket
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import c01_runtime as runtime
from c01_runtime_groups import Invocation
import runtime_owner as owner
class RetirementTests(unittest.TestCase):
def test_only_one_walk_and_emfile_before_cleanup_releases_cursor(self):
with tempfile.TemporaryDirectory() as directory:
groups = []
for name in ('first', 'second'):
path = Path(directory) / name
(path / Path(*(['nested'] * 60))).mkdir(parents=True)
group = Invocation(path)
group.empty = lambda: True
groups.append(group)
first, second = groups
self.assertFalse(first.close())
self.assertFalse(second.close())
self.assertTrue(first.walk)
self.assertFalse(second.walk)
def allocate_control():
os.close(os.open('/dev/null', os.O_RDONLY))
first.kill = allocate_control
job = owner.Job(None, pid=123, status=SimpleNamespace(si_code=os.CLD_EXITED, si_status=0), group=first)
limits = resource.getrlimit(resource.RLIMIT_NOFILE)
try:
# No new FD can be allocated, including by the initial kill.
resource.setrlimit(resource.RLIMIT_NOFILE, (0, limits[1]))
self.assertFalse(job.settle((job,)))
self.assertFalse(first.walk)
self.assertIsNone(Invocation.walker)
self.assertIsNotNone(first.fd)
finally:
resource.setrlimit(resource.RLIMIT_NOFILE, limits)
for group in groups:
for _ in range(100):
if group.close():
break
self.assertIsNone(group.fd)
def test_promoted_child_reaped_after_exit_without_cancelling_live_sibling(self):
for jobs in ((), (owner.Job(None, pid=10, group=SimpleNamespace(path=Path('/sys/fs/cgroup/job'))),)):
with mock.patch('builtins.open', return_value=io.StringIO('10 200 201')), \
mock.patch.object(Path, 'read_text', return_value='0::/persistent.scope'), \
mock.patch.object(owner.os, 'waitid', side_effect=lambda _, pid, flags: object() if pid == 200 else None), \
mock.patch.object(owner.os, 'waitpid') as wait, mock.patch.object(owner.os, 'kill') as kill:
self.assertEqual(owner.reap_adopted(jobs), set())
wait.assert_called_once_with(200, os.WNOHANG)
kill.assert_not_called()
def test_nested_retirement_is_bounded_and_never_touches_sibling(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
job = root / 'job-owned'; job.mkdir()
sibling = root / 'job-other'; sibling.mkdir()
(sibling / 'preserved').write_text('unrelated')
for i in range(80):
(job / str(i) / 'nested').mkdir(parents=True)
group = Invocation(job)
group.empty = lambda: True # Ordinary directories model empty cgroups.
ticks = 0
while not group.close():
ticks += 1
self.assertLess(ticks, 100)
self.assertIsNotNone(group.fd)
self.assertGreater(ticks, 1)
self.assertFalse(job.exists())
self.assertEqual((sibling / 'preserved').read_text(), 'unrelated')
self.assertIsNone(group.fd)
def test_failed_cleanup_retains_leader_and_recovers_without_other_job_loss(self):
group = mock.Mock()
group.empty.return_value = True
group.close.side_effect = [PermissionError('unreadable subtree'), False, True]
status = SimpleNamespace(si_code=os.CLD_EXITED, si_status=0)
job = owner.Job(None, pid=123, status=status, group=group)
other = owner.Job(None, pid=456, status=status, group=mock.Mock())
with mock.patch.object(owner, 'reap_adopted', return_value=set()), \
mock.patch.object(owner.os, 'kill'), mock.patch.object(owner.os, 'waitpid') as wait:
self.assertFalse(job.settle((job, other)))
self.assertIn('unreadable', job.retirement_error)
wait.assert_not_called()
self.assertTrue(other.settle((job, other)))
wait.assert_called_once_with(456, 0)
wait.reset_mock()
self.assertFalse(job.settle((job,)))
wait.assert_not_called()
self.assertTrue(job.settle((job,)))
wait.assert_called_once_with(123, 0)
def test_populated_or_symlink_subtree_retains_ownership(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
job = root / 'job'; job.mkdir()
outside = root / 'outside'; outside.mkdir()
(outside / 'sentinel').write_text('keep')
(job / 'escape').symlink_to(outside, target_is_directory=True)
group = Invocation(job)
group.empty = lambda: False
with self.assertRaises(ValueError):
group.close()
group.empty = lambda: True
with self.assertRaises(OSError):
group.close()
self.assertIsNotNone(group.fd)
self.assertEqual((outside / 'sentinel').read_text(), 'keep')
(job / 'escape').unlink()
self.assertTrue(group.close())
def test_directory_cursor_restarts_after_fallible_nested_removal(self):
with tempfile.TemporaryDirectory() as directory:
job = Path(directory) / 'job'; job.mkdir()
(job / 'nested' / 'leaf').mkdir(parents=True)
group = Invocation(job)
group.empty = lambda: True
remove = os.rmdir
def fail_once(path, *args, **kwargs):
if path == 'leaf':
raise PermissionError('temporary removal refusal')
return remove(path, *args, **kwargs)
with mock.patch.object(os, 'rmdir', side_effect=fail_once):
with self.assertRaises(PermissionError):
group.close()
self.assertTrue(group.close())
self.assertFalse(job.exists())
def test_live_reaping_excludes_all_leaders_and_unowned_children_and_is_bounded(self):
# Both live and exited leaders remain pinned. No signal is needed to
# reap already exited adopted descendants, including nested membership.
job = owner.Job(None, pid=10, group=SimpleNamespace(path=Path('/sys/fs/cgroup/job-a')))
other = owner.Job(None, pid=11, group=SimpleNamespace(path=Path('/sys/fs/cgroup/job-b')))
children = [10, 11, *range(100, 170), 200, 201]
def location(path):
pid = int(str(path).split('/')[2])
return '0::/job-a/nested' if pid < 200 else ('0::/job-other' if pid == 200 else '0::/job-a')
with mock.patch('builtins.open', return_value=io.StringIO(' '.join(map(str, children)))), \
mock.patch.object(Path, 'read_text', autospec=True, side_effect=location), \
mock.patch.object(owner.os, 'waitid', side_effect=lambda _, pid, flags: None if pid == 201 else object()), \
mock.patch.object(owner.os, 'waitpid') as wait, mock.patch.object(owner.os, 'kill') as kill:
self.assertEqual(owner.reap_adopted((job, other)), {10})
self.assertEqual(wait.call_count, owner.MAX_REAPS)
self.assertTrue(all(call.args[0] in range(100, 170) for call in wait.call_args_list))
kill.assert_not_called()
class TemplateTests(unittest.TestCase):
def test_runtime_directory_survives_protect_home_and_is_ordered(self):
text = (Path(__file__).resolve().parents[2] / 'image-recipe/configs/80-archipelago-runtime-user.conf.in').read_text()
text = text.replace('@ORIGINAL_RUNTIME_UID@', '61000').replace('@ORIGINAL_HOME@', '/home/runtime')
text = text.replace('@SANITIZED_RUNTIME_HOME@', '/var/lib/sanitized')
# Preserve repeated BindPaths (ConfigParser would otherwise lose one).
bindings = [line.split('=', 1)[1] for line in text.splitlines() if line.startswith('BindPaths=')]
self.assertEqual(bindings, ['/var/lib/sanitized:/home/runtime', '/run/user/61000'])
config = configparser.ConfigParser(strict=False, interpolation=None)
config.read_string(text)
self.assertEqual(config['Service']['ProtectHome'], 'tmpfs')
for directive in ('Requires', 'After'):
self.assertIn('user-runtime-dir@61000.service', config['Unit'][directive].split())
class ControlTests(unittest.TestCase):
def test_refused_or_truncated_rights_do_not_leak(self):
with open('/dev/null', 'rb') as source:
for count in (1, 20):
with self.subTest(count=count):
left, right = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
try:
before = len(list(Path('/proc/self/fd').iterdir()))
left.sendmsg([b'{}'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS,
array.array('i', [source.fileno()] * count))])
with self.assertRaises(ValueError):
runtime.receive(right)
self.assertEqual(len(list(Path('/proc/self/fd').iterdir())), before)
finally:
left.close(); right.close()
def test_duplicate_json_and_oversize_close_received_capability(self):
with open('/dev/null', 'rb') as source:
for raw in (b'{"nonce":1,"nonce":2}', b'x' * (runtime.LIMIT + 1)):
left, right = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
try:
before = len(list(Path('/proc/self/fd').iterdir()))
left.sendmsg([raw], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array('i', [source.fileno()]))])
with self.assertRaises(ValueError):
runtime.receive(right, 1)
self.assertEqual(len(list(Path('/proc/self/fd').iterdir())), before)
finally:
left.close(); right.close()
def test_successful_capability_is_cloexec_and_exact_inode(self):
with open('/dev/null', 'rb') as source:
left, right = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
try:
runtime.send(left, {'nonce': 'exact'}, [source.fileno()])
value, rights = runtime.receive(right, 1)
try:
self.assertEqual(value, {'nonce': 'exact'})
self.assertFalse(os.get_inheritable(rights[0]))
self.assertEqual(os.fstat(rights[0]), os.fstat(source.fileno()))
finally:
os.close(rights[0])
finally:
left.close(); right.close()
if __name__ == '__main__':
unittest.main()