Files
truf-server/tests/test_container_migration_paths.py
T
2026-09-30 20:30:56 +03:00

372 lines
21 KiB
Python

import contextlib
import hashlib
import ntpath
import os
from pathlib import Path
import posixpath
import sqlite3
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'app'))
import lifecycle_authority
import migrate_runtime_safety as migration
class LegacySpoolPathTests(unittest.TestCase):
def setUp(self):
self.path = SimpleNamespace(**vars(posixpath))
self.path.lexists = mock.Mock(return_value=False)
self.os = SimpleNamespace(name='posix', path=self.path, sep='/')
self.enterContext(mock.patch.object(migration, 'os', self.os))
self.private = self.enterContext(mock.patch.object(migration, 'require_private_directory', side_effect=lambda path, **_: path))
self.reject = self.enterContext(mock.patch.object(migration, 'reject_reparse_components', side_effect=lambda path: path))
self.canonical = self.enterContext(mock.patch.object(migration, 'canonical_path', side_effect=posixpath.normpath))
self.harden = self.enterContext(mock.patch.object(migration, 'harden_private_directory'))
self.ensure = self.enterContext(mock.patch.object(migration, 'ensure_private_directory'))
self.config = {'global': {
'runtime_dir': '/data/runtime-linux',
'legacy_result_spool_dir': '/data/runtime-linux/result_spool',
}}
def test_prepared_exact_spool_is_accepted_without_repair(self):
self.assertEqual(migration.reviewed_legacy_result_spool(self.config), '/data/runtime-linux/result_spool')
self.assertEqual(self.private.call_args_list, [
mock.call('/data/runtime-linux', create=False),
mock.call('/data/runtime-linux/result_spool', create=False),
])
self.harden.assert_not_called()
self.ensure.assert_not_called()
def test_result_spool_fallback_uses_same_exact_authority(self):
self.config['global']['result_spool_dir'] = self.config['global'].pop('legacy_result_spool_dir')
self.assertEqual(migration.reviewed_legacy_result_spool(self.config), '/data/runtime-linux/result_spool')
def test_unresolved_relative_noncanonical_or_different_paths_are_rejected(self):
cases = (
('runtime_dir', None), ('runtime_dir', 'runtime'),
('runtime_dir', '/data/{runtime}'), ('runtime_dir', '/data/runtime/../runtime-linux'),
('legacy_result_spool_dir', None), ('legacy_result_spool_dir', 'result_spool'),
('legacy_result_spool_dir', '{runtime_dir}/result_spool'),
('legacy_result_spool_dir', '/data/runtime-linux/not_result_spool'),
('legacy_result_spool_dir', '/elsewhere/result_spool'),
('legacy_result_spool_dir', '/data/runtime-linux/alias/../result_spool'),
('legacy_result_spool_dir', r'D:\truf\runtime\result_spool'),
)
for key, value in cases:
config = {'global': dict(self.config['global'], **{key: value})}
with self.subTest(key=key, value=value), self.assertRaises(RuntimeError):
migration.reviewed_legacy_result_spool(config)
self.ensure.assert_not_called()
self.harden.assert_not_called()
def test_runtime_or_spool_link_is_rejected_before_canonicalization(self):
for linked in ('/data/runtime-linux', '/data/runtime-linux/result_spool'):
def reject(path):
if path == linked:
raise OSError('link or reparse component')
return path
with self.subTest(linked=linked):
self.reject.side_effect = reject
with self.assertRaisesRegex(OSError, 'link or reparse'):
migration.reviewed_legacy_result_spool(self.config)
self.canonical.assert_not_called()
def test_missing_or_unsafe_prepared_directories_are_not_created_or_repaired(self):
for bad in ('/data/runtime-linux', '/data/runtime-linux/result_spool'):
def require(path, **kwargs):
self.assertEqual(kwargs, {'create': False})
if path == bad:
raise OSError('private directory absent or not ready')
return path
with self.subTest(bad=bad):
self.private.side_effect = require
with self.assertRaisesRegex(OSError, 'absent or not ready'):
migration.reviewed_legacy_result_spool(self.config)
self.ensure.assert_not_called()
self.harden.assert_not_called()
def test_existing_reservation_and_quarantine_directories_must_also_be_private(self):
self.path.lexists.return_value = True
migration.reviewed_legacy_result_spool(self.config)
self.private.assert_any_call('/data/runtime-linux/result_spool/reservations', create=False)
self.private.assert_any_call('/data/runtime-linux/result_spool/quarantine', create=False)
self.private.side_effect = OSError('unsafe shard')
with self.assertRaises(OSError):
migration.reviewed_legacy_result_spool(self.config)
self.ensure.assert_not_called()
def test_canonical_escape_is_rejected(self):
self.canonical.side_effect = lambda path: '/elsewhere' if path.endswith('/result_spool') else path
with self.assertRaisesRegex(RuntimeError, 'outside its runtime authority'):
migration.reviewed_legacy_result_spool(self.config)
def test_windows_reviewed_constant_and_no_new_directory_policy_are_preserved(self):
self.os.name = 'nt'
self.os.path = ntpath
reviewed = ntpath.normcase(r'D:\truf\runtime\result_spool')
with mock.patch.object(migration, 'REVIEWED_LEGACY_RESULT_SPOOL', reviewed):
config = {'global': {'legacy_result_spool_dir': r'D:\truf\runtime\result_spool'}}
self.assertEqual(migration.reviewed_legacy_result_spool(config), r'D:\truf\runtime\result_spool')
config['global']['legacy_result_spool_dir'] = r'E:\fixture\result_spool'
with self.assertRaisesRegex(RuntimeError, 'does not match reviewed'):
migration.reviewed_legacy_result_spool(config)
self.private.assert_not_called()
self.canonical.assert_not_called()
class ImmutableNativeHardeningTests(unittest.TestCase):
def setUp(self):
self.config_path = '/opt/truf/app/config.linux.yaml'
self.env_path = '/run/secrets/postgres.env'
self.config = {'global': {
'root_dir': '/opt/truf', 'project_dir': '/opt/truf/app',
'runtime_dir': '/data/runtime-linux', 'results_dir': '/data/runtime-linux/results',
'result_spool_dir': '/data/runtime-linux/result_spool',
'postgres_data_dir': '/data/postgres-linux',
'work_dir': '/data/scanner-work',
'trufflehog_path': '/usr/local/bin/trufflehog',
'trufflehog_config': '/opt/truf/app/policy.yaml',
'secrets_file': '/run/secrets/truf-secrets.yaml',
}}
self.code_files = ['/opt/truf/app/paths.py', '/opt/truf/app/provider.py']
self.native = ['/usr/local/bin/trufflehog', '/usr/bin/git']
self.pgpaths = {
'postgres_dir': '/data/runtime-linux/postgres', 'data_dir': '/data/postgres-linux',
}
for key in ('postgres', 'pg_ctl', 'pg_isready', 'pg_controldata', 'initdb', 'psql'):
self.pgpaths[key] = '/usr/lib/postgresql/16/bin/' + key
self.native.append(self.pgpaths[key])
self.files = set(self.code_files + self.native + [
self.config_path, self.env_path,
self.config['global']['trufflehog_config'], self.config['global']['secrets_file'],
])
self.path = SimpleNamespace(**vars(posixpath))
self.path.lexists = mock.Mock(side_effect=lambda path: path in self.files)
self.path.isfile = mock.Mock(side_effect=lambda path: path in self.files)
self.os = SimpleNamespace(name='posix', path=self.path, sep='/', makedirs=mock.Mock())
self.enterContext(mock.patch.object(migration, 'os', self.os))
self.enterContext(mock.patch.object(migration, 'postgres_runtime_paths', return_value=self.pgpaths))
self.enterContext(mock.patch.object(migration, 'canonical_cluster_data_directory', return_value='/data/postgres-linux'))
self.enterContext(mock.patch.object(migration, 'reject_reparse_components', side_effect=lambda path: path))
self.enterContext(mock.patch.object(migration, 'private_directory_ready', return_value=True))
self.enterContext(mock.patch.object(migration, 'private_file_ready', return_value=True))
self.harden_dir = self.enterContext(mock.patch.object(migration, 'harden_private_directory'))
self.harden_file = self.enterContext(mock.patch.object(migration, 'harden_private_file'))
self.harden_tree = self.enterContext(mock.patch.object(migration, 'harden_private_tree', return_value=1))
self.trust = self.enterContext(mock.patch.object(migration, 'require_trusted_native_executable', side_effect=lambda path: path))
self.manifest = self.enterContext(mock.patch.object(lifecycle_authority, 'manifest_authority_paths', return_value=self.code_files))
self.enterContext(mock.patch.object(
lifecycle_authority, 'resolve_manifest_executable',
side_effect=lambda value, **kwargs: '/usr/bin/git' if kwargs['name'] == 'git' else '/usr/local/bin/trufflehog',
))
def harden(self):
return migration.harden_runtime_paths(self.config, env_path=self.env_path, config_path=self.config_path)
def test_native_files_and_parents_are_verified_but_never_hardened(self):
self.assertGreater(self.harden(), 0)
self.assertFalse(self.manifest.call_args.kwargs['include_executables'])
self.assertEqual({call.args[0] for call in self.trust.call_args_list}, set(self.native))
self.assertEqual(len(self.trust.call_args_list), len(self.native) * 2)
hardened_files = {call.args[0] for call in self.harden_file.call_args_list}
self.assertTrue(set(self.code_files).issubset(hardened_files))
self.assertTrue({self.config_path, self.env_path}.issubset(hardened_files))
self.assertFalse(hardened_files & set(self.native))
native_parents = {posixpath.dirname(path) for path in self.native}
self.assertFalse(native_parents & {call.args[0] for call in self.harden_dir.call_args_list})
self.assertTrue(any(call.args[0] == '/data/runtime-linux' for call in self.harden_tree.call_args_list))
self.assertTrue(any(call.args[0] == '/data/postgres-linux' for call in self.harden_tree.call_args_list))
for call in self.harden_tree.call_args_list:
for native in self.native:
self.assertNotEqual(posixpath.commonpath((call.args[0], native)), call.args[0])
def test_failed_native_verification_precedes_every_filesystem_mutation(self):
self.trust.side_effect = OSError('untrusted native executable')
with self.assertRaisesRegex(OSError, 'untrusted'):
self.harden()
self.os.makedirs.assert_not_called()
self.harden_dir.assert_not_called()
self.harden_file.assert_not_called()
self.harden_tree.assert_not_called()
def test_private_native_directory_conflicts_fail_without_chmod(self):
for key, value in (
('runtime_dir', '/usr/bin'), ('results_dir', '/usr'),
('secrets_file', '/usr/local/bin/trufflehog'),
):
with self.subTest(key=key):
original = self.config['global'][key]
self.config['global'][key] = value
with self.assertRaisesRegex(OSError, 'overlaps a native executable'):
self.harden()
self.config['global'][key] = original
self.os.makedirs.assert_not_called()
self.harden_dir.assert_not_called()
self.harden_file.assert_not_called()
self.harden_tree.assert_not_called()
def test_windows_keeps_private_executable_and_parent_hardening(self):
self.os.name = 'nt'
self.manifest.return_value = self.code_files + self.native[:2]
self.harden()
self.assertTrue(self.manifest.call_args.kwargs['include_executables'])
self.trust.assert_not_called()
for path in self.native[:2]:
self.harden_file.assert_any_call(path)
self.harden_dir.assert_any_call(posixpath.dirname(path))
class LegacyCutoverChecksTests(unittest.TestCase):
def setUp(self):
self.temp = Path(self.enterContext(tempfile.TemporaryDirectory()))
self.spool = self.temp / 'result_spool'
self.results = self.temp / 'results'
for directory in (self.spool, self.results, self.spool / 'reservations', self.spool / 'quarantine'):
directory.mkdir(mode=0o700)
self.config = {'global': {'result_spool_dir': str(self.spool), 'results_dir': str(self.results)}}
self.enterContext(mock.patch.object(migration, 'reviewed_legacy_result_spool', return_value=str(self.spool)))
self.counts = {'scan_publication_outbox': 0, 'target_scans': 0}
self.conn = mock.Mock(is_postgres=True)
self.conn.table_exists.side_effect = lambda name: name in self.counts
self.conn.execute.side_effect = lambda sql: mock.Mock(fetchone=lambda: {
'count': self.counts['scan_publication_outbox' if 'scan_publication_outbox' in sql else 'target_scans'],
})
self.db = SimpleNamespace(conn=self.conn)
def test_empty_legacy_state_produces_evidence_not_a_bypass(self):
evidence = migration.require_legacy_cutover_clear(self.db, self.config)
self.assertEqual(evidence['legacy_outbox_rows'], 0)
self.assertEqual(evidence['legacy_raw_result_rows'], 0)
self.assertTrue(evidence['legacy_spool_present'])
self.assertEqual(evidence['legacy_prepared_appends'], 0)
self.assertEqual(self.conn.execute.call_count, 2)
self.conn.commit.assert_called_once()
def test_outbox_or_legacy_raw_rows_still_block_cutover(self):
for table in self.counts:
with self.subTest(table=table):
self.counts[table] = 1
with self.assertRaisesRegex(RuntimeError, 'final cutover refused'):
migration.require_legacy_cutover_clear(self.db, self.config)
self.counts[table] = 0
self.conn.commit.assert_not_called()
def test_json_in_spool_reservations_or_quarantine_still_blocks_cutover(self):
for parent in (self.spool, self.spool / 'reservations', self.spool / 'quarantine'):
path = parent / 'unresolved.json'
with self.subTest(parent=parent):
path.write_text('{}', encoding='ascii')
with self.assertRaisesRegex(RuntimeError, 'unresolved objects'):
migration.require_legacy_cutover_clear(self.db, self.config)
path.unlink()
self.conn.commit.assert_not_called()
def test_prepared_legacy_ledger_append_still_blocks_cutover(self):
path = self.results / 'scan_results.publication-ledger.sqlite3'
connection = sqlite3.connect(path)
try:
connection.execute('CREATE TABLE publication_identity(state TEXT)')
connection.execute("INSERT INTO publication_identity VALUES ('prepared')")
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(RuntimeError, 'prepared append'):
migration.require_legacy_cutover_clear(self.db, self.config)
self.conn.commit.assert_not_called()
def test_spool_entry_bound_still_fails_closed(self):
with self.assertRaisesRegex(RuntimeError, 'entry bound'):
migration.require_legacy_cutover_clear(self.db, self.config, max_entries=1)
class NormalMigrationContractTests(unittest.TestCase):
def setUp(self):
self.argv = ['migrate_runtime_safety.py', '--config', 'fixture.yaml', '--initialize-base', '--apply', '--sources-stopped']
self.enterContext(mock.patch.object(sys, 'argv', self.argv))
self.config = {'global': {}}
self.dsn = 'postgresql://fixture:fixture@127.0.0.1:55432/fixture'
self.enterContext(mock.patch.object(migration, 'load_config', return_value=self.config))
self.enterContext(mock.patch.object(migration, 'load_postgres_environment'))
self.enterContext(mock.patch.object(migration, 'database_url_from_env', return_value=self.dsn))
self.enterContext(mock.patch.object(migration, 'preflight_lifecycle_paths'))
self.lock = self.enterContext(mock.patch.object(migration, 'ClusterAuthorityLock', return_value=contextlib.nullcontext()))
self.stopped = self.enterContext(mock.patch.object(migration, 'require_local_sources_stopped'))
self.enterContext(mock.patch.object(migration, 'verify_cluster_identity', return_value={
'database': 'fixture', 'user': 'fixture', 'port': 55432,
}))
self.enterContext(mock.patch.object(migration, '_online_postgres_identity'))
self.guard = self.enterContext(mock.patch.object(migration, 'postgres_migration_guard', return_value=contextlib.nullcontext()))
self.events = []
self.db = mock.Mock(enabled=True)
self.db.conn.is_postgres = True
self.db.conn.table_exists.return_value = True
self.migrations = [
{'version': version, 'code_sha256': hashlib.sha256(version.encode('ascii')).hexdigest()}
for version in migration.PIPELINE_MIGRATION_VERSIONS
]
self.db.conn.execute.return_value.fetchall.return_value = self.migrations
self.db.revoke_final_cutover.side_effect = lambda: self.events.append('revoke')
self.db.require_runtime_safety_schema.side_effect = lambda: self.events.append('verify-schema')
self.db.record_final_cutover.side_effect = lambda evidence: self.events.append('record') or {'evidence_sha256': 'fixture-evidence'}
self.db.require_final_cutover.side_effect = lambda: self.events.append('verify-cutover')
self.construct = self.enterContext(mock.patch.object(migration, 'ScannerDB', return_value=self.db))
self.schema = self.enterContext(mock.patch.object(migration, 'migrate_runtime_safety_schema', side_effect=lambda *_, **__: self.events.append('migrate')))
self.cursors = self.enterContext(mock.patch.object(migration, 'initialize_projection_cursors_from_existing_files', side_effect=lambda *args: self.events.append('cursors') or {'cursor-evidence': True}))
self.legacy = self.enterContext(mock.patch.object(migration, 'require_legacy_cutover_clear', side_effect=lambda *args: self.events.append('legacy') or {'legacy-evidence': True}))
self.enterContext(mock.patch('builtins.print'))
def test_normal_initialize_base_runs_every_gate_and_records_all_30_migration_rows(self):
self.assertEqual(migration.main(), 0)
self.schema.assert_called_once_with(self.db, initialize_base=True)
self.assertEqual(self.events, ['revoke', 'migrate', 'cursors', 'legacy', 'verify-schema', 'record', 'verify-cutover'])
self.assertEqual(len(self.migrations), 32)
self.db.record_final_cutover.assert_called_once_with({
'legacy': {'legacy-evidence': True},
'projection_cursors': {'cursor-evidence': True},
'schema_migrations': self.migrations,
})
self.assertEqual(self.stopped.call_count, 2)
self.guard.assert_called_once_with(self.db)
self.lock.assert_called_once_with(self.config, endpoint_dsn=self.dsn)
self.db.close.assert_called_once()
def test_both_explicit_apply_and_sources_stopped_are_required(self):
for flag in ('--apply', '--sources-stopped'):
with self.subTest(flag=flag), mock.patch.object(sys, 'argv', [arg for arg in self.argv if arg != flag]):
with self.assertRaisesRegex(SystemExit, 'both --apply and --sources-stopped'):
migration.main()
self.construct.assert_not_called()
self.lock.assert_not_called()
def test_legacy_failure_cannot_record_or_validate_cutover(self):
self.legacy.side_effect = RuntimeError('legacy state unresolved')
with self.assertRaisesRegex(RuntimeError, 'legacy state unresolved'):
migration.main()
self.db.revoke_final_cutover.assert_called_once()
self.schema.assert_called_once_with(self.db, initialize_base=True)
self.db.record_final_cutover.assert_not_called()
self.db.require_final_cutover.assert_not_called()
self.db.close.assert_called_once()
def test_schema_failure_does_not_skip_ahead_to_cursors_or_marker(self):
self.schema.side_effect = RuntimeError('migration failed')
with self.assertRaisesRegex(RuntimeError, 'migration failed'):
migration.main()
self.cursors.assert_not_called()
self.legacy.assert_not_called()
self.db.record_final_cutover.assert_not_called()
self.db.close.assert_called_once()
if __name__ == '__main__':
unittest.main()