223 lines
11 KiB
Python
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()
|