"""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()