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