144 lines
5.7 KiB
Python
144 lines
5.7 KiB
Python
import os
|
|
from pathlib import Path
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
import uuid
|
|
from unittest import mock
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
APP_DIR = ROOT / 'app'
|
|
sys.path.insert(0, str(APP_DIR))
|
|
|
|
import result_spool
|
|
from result_spool import ResultSpool, SpoolCorruptionError
|
|
from runtime_security import harden_private_file, private_file_ready
|
|
|
|
|
|
def write_private_orphan(path, payload=b'orphaned-after-fsync'):
|
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
if hasattr(os, 'O_BINARY'):
|
|
flags |= os.O_BINARY
|
|
descriptor = os.open(path, flags, 0o600)
|
|
try:
|
|
harden_private_file(path)
|
|
with os.fdopen(descriptor, 'wb') as handle:
|
|
descriptor = None
|
|
handle.write(payload)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
finally:
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
|
|
|
|
class ResultSpoolTempRecoveryTests(unittest.TestCase):
|
|
def make_spool(self, directory):
|
|
return ResultSpool(directory, min_free_bytes=0)
|
|
|
|
def test_root_and_quarantine_orphans_are_durably_recovered_before_usage(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
spool = self.make_spool(os.path.join(temp_dir, 'spool'))
|
|
event_id = str(uuid.uuid4())
|
|
root_orphan = f'{spool._event_path(event_id)}.101.202.303.tmp'
|
|
quarantine_final = os.path.join(
|
|
spool.quarantine_directory,
|
|
f'{event_id}.404.505.conflict.json',
|
|
)
|
|
quarantine_orphan = f'{quarantine_final}.606.707.808.tmp'
|
|
write_private_orphan(root_orphan)
|
|
write_private_orphan(quarantine_orphan)
|
|
self.assertTrue(private_file_ready(root_orphan))
|
|
self.assertTrue(private_file_ready(quarantine_orphan))
|
|
|
|
with mock.patch.object(
|
|
result_spool, 'durable_unlink', wraps=result_spool.durable_unlink,
|
|
) as durable_unlink:
|
|
self.assertEqual(spool.pending_events(), [])
|
|
|
|
removed = {os.path.normcase(call.args[0]) for call in durable_unlink.call_args_list}
|
|
self.assertEqual(
|
|
removed,
|
|
{os.path.normcase(root_orphan), os.path.normcase(quarantine_orphan)},
|
|
)
|
|
self.assertFalse(os.path.lexists(root_orphan))
|
|
self.assertFalse(os.path.lexists(quarantine_orphan))
|
|
|
|
def test_reservation_orphan_is_recovered_before_reservation_load(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
spool = self.make_spool(os.path.join(temp_dir, 'spool'))
|
|
reservation_id = spool.reserve_claims('owner', 1, 60)
|
|
reservation_path = spool._reservation_path(reservation_id)
|
|
orphan = f'{reservation_path}.101.202.303.tmp'
|
|
write_private_orphan(orphan)
|
|
|
|
with mock.patch.object(
|
|
result_spool, 'durable_unlink', wraps=result_spool.durable_unlink,
|
|
) as durable_unlink:
|
|
self.assertTrue(spool.assert_claims_allowed())
|
|
|
|
durable_unlink.assert_called_once_with(orphan)
|
|
self.assertFalse(os.path.lexists(orphan))
|
|
self.assertTrue(os.path.exists(reservation_path))
|
|
|
|
def test_full_event_capacity_still_has_recovery_headroom(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
spool = ResultSpool(
|
|
os.path.join(temp_dir, 'spool'), max_events=1,
|
|
max_event_bytes=1024 * 1024, max_total_bytes=1024 * 1024,
|
|
min_free_bytes=0,
|
|
)
|
|
event_id = str(uuid.uuid4())
|
|
spool.write_event({'version': 1, 'scan_event_id': event_id, 'result': {}})
|
|
orphan = f'{spool._event_path(str(uuid.uuid4()))}.101.202.303.tmp'
|
|
write_private_orphan(orphan)
|
|
|
|
events = spool.pending_events()
|
|
|
|
self.assertEqual([event.event_id for event in events], [event_id])
|
|
self.assertFalse(os.path.lexists(orphan))
|
|
|
|
def test_invalid_prospective_final_name_is_left_and_blocks(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
spool = self.make_spool(os.path.join(temp_dir, 'spool'))
|
|
unrelated = os.path.join(
|
|
spool.reservation_directory,
|
|
'not-an-internal-reservation.json.101.202.303.tmp',
|
|
)
|
|
write_private_orphan(unrelated)
|
|
|
|
with mock.patch.object(
|
|
result_spool, 'durable_unlink', wraps=result_spool.durable_unlink,
|
|
) as durable_unlink:
|
|
with self.assertRaises(SpoolCorruptionError):
|
|
spool.assert_claims_allowed()
|
|
|
|
durable_unlink.assert_not_called()
|
|
self.assertTrue(os.path.exists(unrelated))
|
|
|
|
def test_matching_symlink_is_left_and_blocks_when_supported(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
spool = self.make_spool(os.path.join(temp_dir, 'spool'))
|
|
external = os.path.join(temp_dir, 'external.txt')
|
|
Path(external).write_text('external-data', encoding='ascii')
|
|
link = f'{spool._event_path(str(uuid.uuid4()))}.101.202.303.tmp'
|
|
try:
|
|
os.symlink(external, link)
|
|
except (OSError, NotImplementedError):
|
|
self.skipTest('symlink creation is unavailable on this platform')
|
|
|
|
with mock.patch.object(
|
|
result_spool, 'durable_unlink', wraps=result_spool.durable_unlink,
|
|
) as durable_unlink:
|
|
with self.assertRaises(SpoolCorruptionError):
|
|
spool.pending_events()
|
|
|
|
durable_unlink.assert_not_called()
|
|
self.assertTrue(os.path.lexists(link))
|
|
self.assertEqual(Path(external).read_text(encoding='ascii'), 'external-data')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|