import copy import os from pathlib import Path import sys import tempfile from types import SimpleNamespace import unittest from unittest import mock import yaml ROOT = Path(__file__).resolve().parents[1] APP_DIR = ROOT / 'app' sys.path.insert(0, str(APP_DIR)) import console_runner import docker_depth_experiment as depth_config import scanner import supervisor TEST_QUERIES = [f'query-{index:02d}' for index in range(61)] def valid_config(enabled=False): experiment = { 'experiment_key': 'docker-depth-test-v1', 'enabled': enabled, 'queries': list(TEST_QUERIES), 'repositories_per_query': 10, 'shallow_images_per_repository': 1, 'deep_repositories_per_query': 1, 'deep_images_per_repository': 10, 'target_limit': 1200, 'selector_version': depth_config.DOCKER_DEPTH_SELECTOR_VERSION, } return { 'global': { 'database_url': 'postgresql://example.invalid/truf', 'sync_file_queues': False, }, 'sources': { 'dockerhub': { 'mode': 'search', 'require_digest': True, 'queries': list(TEST_QUERIES), 'pages': 30, 'per_page': 100, 'docker_platform_filter_enabled': True, 'docker_platform_os': 'linux', 'docker_platform_arch': 'amd64', 'docker_platform_candidate_tags': 20, 'docker_images_per_repository': 3, 'docker_depth_experiment': experiment, }, }, } def layer(value): return 'sha256:' + f'{value:064x}' def graph_candidate(target, updated_at, source_index, layers): return { 'target': target, 'updated_at': updated_at, 'source_index': source_index, 'layers': tuple(layer(value) for value in layers), } class DockerDepthConfigurationTests(unittest.TestCase): def test_image_depth_accepts_only_strict_integers_from_one_through_ten(self): self.assertEqual(depth_config.validate_docker_images_per_repository(1), 1) self.assertEqual(depth_config.validate_docker_images_per_repository(10), 10) for value in ( False, True, None, '1', '10', 1.0, 10.0, [], {}, 0, -1, 11, ): with self.subTest(value=value), self.assertRaises(ValueError): depth_config.validate_docker_images_per_repository(value) with self.subTest(scanner_value=value), self.assertRaises(ValueError): scanner.docker_images_per_repository_limit(value) def test_experiment_mapping_rejects_unknown_missing_and_malformed_ids(self): config = valid_config() config['sources']['dockerhub']['docker_depth_experiment']['unexpected'] = 1 with self.assertRaisesRegex(ValueError, 'unsupported keys: unexpected'): depth_config.validate_docker_depth_config(config) config = valid_config() del config['sources']['dockerhub']['docker_depth_experiment']['target_limit'] with self.assertRaisesRegex(ValueError, 'missing required keys: target_limit'): depth_config.validate_docker_depth_config(config) for identifier in ('', 'Docker Depth', '../depth', 'depth/', '-depth'): config = valid_config() config['sources']['dockerhub']['docker_depth_experiment']['experiment_key'] = identifier with self.subTest(identifier=identifier), self.assertRaisesRegex( ValueError, 'stable identifier', ): depth_config.validate_docker_depth_config(config) def test_experiment_queries_candidate_depth_cap_and_fixed_limits_fail_closed(self): config = valid_config() config['sources']['dockerhub']['docker_depth_experiment']['queries'][-1] = TEST_QUERIES[0] config['sources']['dockerhub']['queries'][-1] = TEST_QUERIES[0] with self.assertRaisesRegex(ValueError, 'must be unique'): depth_config.validate_docker_depth_config(config) config = valid_config() config['sources']['dockerhub']['queries'] = list(reversed(TEST_QUERIES)) with self.assertRaisesRegex(ValueError, 'exactly match ordered'): depth_config.validate_docker_depth_config(config) config = valid_config() config['sources']['dockerhub']['docker_platform_candidate_tags'] = 9 with self.assertRaisesRegex(ValueError, 'at least the configured deep image depth'): depth_config.validate_docker_depth_config(config) config = valid_config(enabled=True) config['sources']['dockerhub']['docker_repository_refresh_max_per_cycle'] = 1 with self.assertRaisesRegex( ValueError, 'docker_repository_refresh_max_per_cycle=0', ): depth_config.validate_docker_depth_config(config) config = valid_config(enabled=False) config['sources']['dockerhub']['docker_repository_refresh_max_per_cycle'] = 1 self.assertIsNotNone( depth_config.validate_docker_depth_config(config).experiment ) config = valid_config() config['sources']['dockerhub']['docker_depth_experiment']['target_limit'] = 1158 with self.assertRaisesRegex(ValueError, 'theoretical target maximum'): depth_config.validate_docker_depth_config(config) for key, value in ( ('repositories_per_query', 9), ('shallow_images_per_repository', 2), ('deep_repositories_per_query', 2), ('deep_images_per_repository', 9), ('target_limit', 1201), ): config = valid_config() config['sources']['dockerhub']['docker_depth_experiment'][key] = value with self.subTest(key=key), self.assertRaises(ValueError): depth_config.validate_docker_depth_config(config) def test_disabled_collection_keeps_ordinary_fifo_resolution_at_three(self): config = valid_config(enabled=False) validated = depth_config.validate_docker_depth_config(config) source = validated.normalized_config['sources']['dockerhub'] args = console_runner.build_args_from_source_config( 'dockerhub', source, validated.normalized_config['global'], TEST_QUERIES[0], ) self.assertEqual(args.docker_images_per_repository, 3) self.assertEqual(validated.experiment.deep_images_per_repository, 10) invalid = valid_config(enabled=False) invalid['sources']['dockerhub']['docker_images_per_repository'] = 10 with self.assertRaisesRegex(ValueError, 'ordinary resolver depth 3'): depth_config.validate_docker_depth_config(invalid) class DB: last_error = '' def __init__(self): self.claimed = False self.finished = [] def claim_docker_resolutions(self, *_args, **_kwargs): if self.claimed: return [] self.claimed = True return [{'id': 1, 'target': 'owner/repository', 'resolver_token': 'token'}] def finish_docker_resolution(self, *values, **kwargs): self.finished.append((values, kwargs)) return True selected = [] def fetch(_repository, _since, limit, *_args, **_kwargs): candidates = [ graph_candidate(f'image-{index}', 100 - index, index, (index + 1,)) for index in range(10) ] selected.extend(scanner.select_docker_layer_graphs(candidates, limit)) return SimpleNamespace( tags=tuple(item['target'] for item in selected), status='ok', remote_attempted=True, retry_at=None, error='', ) database = DB() with mock.patch.object(console_runner, 'fetch_dockerhub_tags', side_effect=fetch), \ mock.patch('builtins.print'): self.assertEqual( console_runner.resolve_due_docker_queue_targets( database, 'dockerhub', args, ), 1, ) self.assertEqual([item['rank'] for item in selected], [1, 2, 3]) self.assertEqual(len(database.finished[0][0][3]), 3) def test_enabled_and_disabled_collection_require_all_static_authorities(self): for enabled in (False, True): config = valid_config(enabled=enabled) validated = depth_config.validate_docker_depth_config(config) self.assertIs(validated.experiment.enabled, enabled) with self.subTest(enabled=enabled), self.assertRaisesRegex( ValueError, 'managed PostgreSQL', ): depth_config.validate_docker_depth_config( config, managed_postgres=False, final_cutover=True, ) with self.subTest(enabled=enabled), self.assertRaisesRegex( ValueError, 'final cutover', ): depth_config.validate_docker_depth_config( config, managed_postgres=True, final_cutover=False, ) for key, value, message in ( ('mode', 'recent', 'search mode'), ('require_digest', False, 'require_digest=true'), ('sync_file_queues', True, 'final cutover'), ): invalid = valid_config(enabled=enabled) invalid['sources']['dockerhub'][key] = value with self.subTest(enabled=enabled, key=key), self.assertRaisesRegex( ValueError, message, ): depth_config.validate_docker_depth_config(invalid) invalid = valid_config(enabled=enabled) invalid['global']['database_url'] = 'sqlite:///scanner.db' with self.subTest(enabled=enabled), self.assertRaisesRegex( ValueError, 'PostgreSQL database_url', ): depth_config.validate_docker_depth_config( invalid, managed_postgres=True, ) def test_disabled_collection_rejects_late_policy_and_platform_coercions(self): for key, value, message in ( ('pages', '30', 'pages must be an integer'), ('per_page', True, 'per_page must be an integer'), ('docker_platform_candidate_tags', '20', 'must be an integer'), ('docker_platform_candidate_tags', 101, 'must be from 1 through 100'), ('docker_platform_filter_enabled', 'true', 'must be a boolean'), ('docker_platform_os', 'windows', 'supports only linux/amd64'), ('docker_platform_arch', 'arm64', 'supports only linux/amd64'), ): invalid = valid_config(enabled=False) invalid['sources']['dockerhub'][key] = value with self.subTest(key=key, value=value), self.assertRaisesRegex( ValueError, message, ): depth_config.validate_docker_depth_config(invalid) invalid = valid_config(enabled=False) invalid['sources']['dockerhub']['query_overrides'] = [] with self.assertRaisesRegex(ValueError, 'query_overrides must be a mapping'): depth_config.validate_docker_depth_config(invalid) invalid = valid_config(enabled=False) invalid['sources']['dockerhub']['query_overrides'] = { 'not-configured': {'pages': 30}, } with self.assertRaisesRegex(ValueError, 'unconfigured queries'): depth_config.validate_docker_depth_config(invalid) invalid = valid_config(enabled=False) invalid['sources']['dockerhub']['query_overrides'] = { TEST_QUERIES[0]: {'pages': '30'}, } with self.assertRaisesRegex(ValueError, 'pages.*must be an integer'): depth_config.validate_docker_depth_config(invalid) def test_mixed_experiment_discovery_policies_are_rejected(self): config = valid_config(enabled=False) config['sources']['dockerhub']['query_overrides'] = { TEST_QUERIES[0]: {'pages': 29}, } with self.assertRaisesRegex(ValueError, 'one consistent pages/per_page'): depth_config.validate_docker_depth_config(config) def test_validation_is_side_effect_free_and_hashes_are_canonical(self): config = valid_config() original = copy.deepcopy(config) first = depth_config.validate_docker_depth_config(config) self.assertEqual(config, original) self.assertIsNot(first.normalized_config, config) self.assertEqual(first.experiment.theoretical_max_targets, 1159) reordered = copy.deepcopy(config) mapping = reordered['sources']['dockerhub']['docker_depth_experiment'] reordered['sources']['dockerhub']['docker_depth_experiment'] = dict( reversed(list(mapping.items())) ) second = depth_config.validate_docker_depth_config(reordered) self.assertEqual( (first.ordered_query_hash, first.config_hash, first.selector_hash), (second.ordered_query_hash, second.config_hash, second.selector_hash), ) changed = copy.deepcopy(config) changed_queries = list(reversed(TEST_QUERIES)) changed['sources']['dockerhub']['queries'] = changed_queries changed['sources']['dockerhub']['docker_depth_experiment']['queries'] = changed_queries third = depth_config.validate_docker_depth_config(changed) self.assertNotEqual(first.ordered_query_hash, third.ordered_query_hash) self.assertNotEqual(first.config_hash, third.config_hash) changed_policy = copy.deepcopy(config) changed_policy['sources']['dockerhub']['pages'] = 29 policy_hash = depth_config.validate_docker_depth_config(changed_policy) self.assertNotEqual(first.config_hash, policy_hash.config_hash) without_filter = copy.deepcopy(config) without_filter['sources']['dockerhub']['docker_platform_filter_enabled'] = False fourth = depth_config.validate_docker_depth_config(without_filter) self.assertNotEqual(first.config_hash, fourth.config_hash) arm64 = copy.deepcopy(without_filter) arm64['sources']['dockerhub']['docker_platform_arch'] = 'arm64' fifth = depth_config.validate_docker_depth_config(arm64) self.assertNotEqual(fourth.config_hash, fifth.config_hash) windows = copy.deepcopy(without_filter) windows['sources']['dockerhub']['docker_platform_os'] = 'windows' sixth = depth_config.validate_docker_depth_config(windows) self.assertNotEqual(fourth.config_hash, sixth.config_hash) enabled = valid_config(enabled=True) activated = depth_config.validate_docker_depth_config(enabled) self.assertFalse(first.experiment.enabled) self.assertTrue(activated.experiment.enabled) self.assertEqual(first.config_hash, activated.config_hash) unauthorized = copy.deepcopy(enabled) unauthorized['sources']['dockerhub']['pages'] = 29 self.assertNotEqual( activated.config_hash, depth_config.validate_docker_depth_config(unauthorized).config_hash, ) def test_production_returns_to_default_docker_depth(self): with open(APP_DIR / 'config.yaml', 'r', encoding='utf-8') as handle: configured = yaml.safe_load(handle) validated = depth_config.validate_docker_depth_config(configured) source = configured['sources']['dockerhub'] self.assertEqual(source['docker_images_per_repository'], 3) self.assertNotIn('docker_depth_experiment', source) self.assertIsNone(validated.experiment) class DockerDepthStartupOrderingTests(unittest.TestCase): def invalid_config_path(self, directory): path = Path(directory) / 'invalid-config.yaml' config = valid_config(enabled=False) config['sources']['dockerhub']['pages'] = '30' path.write_text(yaml.safe_dump(config), encoding='utf-8') return str(path) def test_supervisor_validation_precedes_lifecycle_workers_secrets_state_and_database(self): with tempfile.TemporaryDirectory() as directory: path = self.invalid_config_path(directory) args = SimpleNamespace( config=path, sources=None, dry_run=False, background_status=False, cmd=None, stop_background=False, attach=False, background=True, with_postgres=True, background_child=False, ) with mock.patch.object(supervisor, 'parse_args', return_value=args), \ mock.patch.dict(os.environ, { supervisor.RUNTIME_BOOTSTRAP_ENV: supervisor.RUNTIME_BOOTSTRAP_VALUE, }, clear=False), \ mock.patch.object(supervisor, 'preflight_lifecycle_paths') as lifecycle, \ mock.patch.object(supervisor, 'start_background') as background, \ mock.patch.object(supervisor, 'load_postgres_env') as secrets, \ mock.patch.object(supervisor, 'ManagedSource') as worker, \ mock.patch.object(supervisor, 'connect_postgres') as database: with self.assertRaisesRegex(ValueError, 'pages must be an integer'): supervisor.main() for side_effect in (lifecycle, background, secrets, worker, database): side_effect.assert_not_called() def test_scanner_child_validation_precedes_lifecycle_state_network_secrets_and_database(self): with tempfile.TemporaryDirectory() as directory: path = self.invalid_config_path(directory) args = SimpleNamespace(show_state=False, config=path) with mock.patch.object(console_runner, 'parse_args', return_value=args), \ mock.patch.dict(os.environ, { 'TRUF_MANAGED_POSTGRES_DSN': 'postgresql://managed.invalid/truf', }, clear=False), \ mock.patch.object(console_runner, 'require_active_supervisor_child') as lifecycle, \ mock.patch.object(console_runner, 'load_state') as state, \ mock.patch.object(console_runner, 'initialize_scanner_runtime') as network, \ mock.patch.object(console_runner, 'load_secrets') as secrets, \ mock.patch.object(console_runner, 'ScannerDB') as database: with self.assertRaisesRegex(ValueError, 'pages must be an integer'): console_runner.main() for side_effect in (lifecycle, state, network, secrets, database): side_effect.assert_not_called() class DockerLayerGraphSelectionTests(unittest.TestCase): def test_selector_hash_binds_executable_source_decisions_and_version(self): version = depth_config.DOCKER_DEPTH_SELECTOR_VERSION baseline = depth_config.canonical_selector_hash(version) self.assertIs(scanner.select_docker_layer_graphs, depth_config.select_docker_layer_graphs) with mock.patch.object( depth_config, '_SELECTOR_LATER_SCORE', 'changed_marginal_layer_rule', ): self.assertNotEqual( baseline, depth_config.canonical_selector_hash(version), ) getsource = depth_config.inspect.getsource def changed_selector_source(function): source = getsource(function) if function is depth_config.select_docker_layer_graphs: original = 'while len(selected) < limit and distinct:' self.assertIn(original, source) return source.replace( original, 'while len(selected) <= limit and distinct:', 1, ) return source with mock.patch.object( depth_config.inspect, 'getsource', side_effect=changed_selector_source, ): self.assertNotEqual( baseline, depth_config.canonical_selector_hash(version), ) self.assertNotEqual( baseline, depth_config.canonical_selector_hash(version + '-changed'), ) def first_three(self): return [ graph_candidate('newest', 100, 0, (1, 2)), graph_candidate('maximum-novelty', 80, 1, (3, 4, 5)), graph_candidate('oldest', 0, 2, (6,)), ] def test_first_three_semantics_and_metadata_are_preserved(self): selected = scanner.select_docker_layer_graphs(self.first_three(), 3) self.assertEqual( [record['target'] for record in selected], ['newest', 'maximum-novelty', 'oldest'], ) self.assertEqual( [record['reason'] for record in selected], [ 'newest_distinct_graph', 'maximum_marginal_layer_novelty', 'oldest_distinct_graph', ], ) self.assertEqual([record['rank'] for record in selected], [1, 2, 3]) self.assertEqual(selected[0]['image_rank'], 1) self.assertEqual(selected[0]['graph'], selected[0]['layers']) self.assertEqual(selected[0]['graph_hash'], selected[0]['graph_sha256']) self.assertEqual(selected[0]['layer_count'], 2) self.assertEqual( selected[0]['layer_metadata'], ( {'digest': layer(1), 'position_from_base': 1, 'position_from_top': 2}, {'digest': layer(2), 'position_from_base': 2, 'position_from_top': 1}, ), ) def test_ranks_four_through_ten_are_deterministic_and_skip_bad_or_duplicate_graphs(self): candidates = self.first_three() + [ graph_candidate(f'candidate-{index}', 75 - index, index, (index + 10,)) for index in range(3, 14) ] candidates.extend([ graph_candidate('newest-alias', 99, 99, (1, 2)), {'target': 'empty', 'updated_at': 200, 'source_index': 100, 'layers': ()}, {'target': 'malformed', 'updated_at': 201, 'source_index': 101, 'layers': ('not-a-digest',)}, ]) selected = scanner.select_docker_layer_graphs(candidates, 10) reversed_selected = scanner.select_docker_layer_graphs(list(reversed(candidates)), 10) self.assertEqual(len(selected), 10) self.assertEqual([item['rank'] for item in selected], list(range(1, 11))) self.assertEqual( [item['target'] for item in selected], [item['target'] for item in reversed_selected], ) self.assertEqual(len({item['graph_hash'] for item in selected}), 10) self.assertNotIn('newest-alias', [item['target'] for item in selected]) self.assertNotIn('empty', [item['target'] for item in selected]) self.assertNotIn('malformed', [item['target'] for item in selected]) def test_later_rank_ties_use_temporal_recency_target_and_graph_hash_order(self): cases = [ ( [ graph_candidate('temporally-far', 40, 10, (20,)), graph_candidate('temporally-near', 60, 11, (21,)), ], 'temporally-far', ), ( [ graph_candidate('older', 30, 10, (22,)), graph_candidate('newer', 50, 11, (23,)), ], 'newer', ), ( [ graph_candidate('repo/b', 50, 10, (24,)), graph_candidate('repo/a', 50, 11, (25,)), ], 'repo/a', ), ] for candidates, expected_target in cases: with self.subTest(expected_target=expected_target): selected = scanner.select_docker_layer_graphs( self.first_three() + candidates, 4, ) self.assertEqual(selected[3]['target'], expected_target) graph_tie = [ graph_candidate('same-target', 50, 10, (26,)), graph_candidate('same-target', 50, 11, (27,)), ] expected_hash = min( depth_config.canonical_docker_layer_graph_hash((layer(26),)), depth_config.canonical_docker_layer_graph_hash((layer(27),)), ) selected = scanner.select_docker_layer_graphs(self.first_three() + graph_tie, 4) self.assertEqual(selected[3]['graph_hash'], expected_hash) def test_resolution_outcome_metadata_fields_default_for_existing_callers(self): outcome = scanner.DockerTagResolutionOutcome( tags=(), status='empty', remote_attempted=False, ) self.assertEqual(outcome.selection_records, ()) self.assertEqual(outcome.selections, ()) self.assertEqual(outcome.selector_version, '') self.assertEqual(outcome.selector_hash, '') if __name__ == '__main__': unittest.main()