diff --git a/.github/workflows/qualify-released-linux-sdk.yml b/.github/workflows/qualify-released-linux-sdk.yml index 4729572..85e1943 100644 --- a/.github/workflows/qualify-released-linux-sdk.yml +++ b/.github/workflows/qualify-released-linux-sdk.yml @@ -14,6 +14,7 @@ on: - tools/scan-released-sdk-filesystem.py - tools/tests/test_released_sdk_inventory.py - tools/tests/test_released_sdk_filesystem.py + - tools/tests/test_released_sdk_diagnostics.py - tools/RELEASED_SDK_INVENTORY.md workflow_dispatch: diff --git a/tools/collect-released-sdk-metadata.py b/tools/collect-released-sdk-metadata.py index d337aa0..aaebd00 100644 --- a/tools/collect-released-sdk-metadata.py +++ b/tools/collect-released-sdk-metadata.py @@ -932,7 +932,10 @@ def target_policy(target): return value -def collect_final_inventory(container, data, file_records, llvm_tools, policy): +def collect_final_inventory(container, data, file_records, llvm_tools, policy, *, progress=None): + # Only fixed local stages enter diagnostics; never paths or remote contents. + progress = progress if progress is not None else {} + progress['stage'] = 'metadata' protocol = SimpleNamespace(BINARY_LIMIT=BINARY_LIMIT, stat_matches=stat_matches, regular_mode=regular_mode, path_stat=path_stat, single_file_archive=single_file_archive, pax_times=pax_times) reader = INVENTORY.InstalledInventory(container, protocol) @@ -944,18 +947,26 @@ def collect_final_inventory(container, data, file_records, llvm_tools, policy): require(contract.get('consumer', {}).get('commit') == policy['consumer_commit'] and contract['consumer'].get('repository') == 'atrinik/classic' and shader.get('source_commit') == policy['consumer_commit'], 'final-consumer-mismatch') + progress['stage'] = 'producer-tree' maps = {'producer': reader.producer()} + progress['stage'] = 'metadata-binding' INVENTORY.bind_metadata(maps['producer'], raw, records) + progress['stage'] = 'source-archives-and-providers' INVENTORY.crosscheck_archives(maps['producer'], documents['runtime-sources.json'], contract, reader=reader) + progress['stage'] = 'shader-inputs' inputs = shader.get('source_inputs') require(isinstance(inputs, dict) and 0 < len(inputs) <= INVENTORY.MAX_MEMBERS, 'final-shader-inputs') for path, sha in inputs.items(): INVENTORY.relative_path(path) item = maps['producer'].get('sources/classic-shaders/' + path) require(isinstance(item, dict) and item.get('sha256') == sha, 'final-shader-input-bytes') + progress['stage'] = 'debian-notices' maps['debian_notices'] = reader.debian_notices(documents['runtime-sources.json'], documents['debian-sources.json']) + progress['stage'] = 'common-licenses' maps['common_licenses'] = reader.common_licenses() + progress['stage'] = 'build-tools' tools = reader.build_tools(documents['installed.json']) + progress['stage'] = 'inventory-report' require(set(llvm_tools) == set(TOOL_FILES) == set(INVENTORY.LLVM_TOOL_FILES), 'final-llvm-roles') llvm = {role: {key: item[key] for key in ('path', 'canonical_path', 'sha256', 'size')} | {'executable': bool(int(item['mode'], 8) & 0o111)} for role, item in llvm_tools.items()} @@ -1147,6 +1158,7 @@ def collect(target, context, original, folder): 'claim': 'installed-identity-only', 'image_code_executed': False, 'final_portable_cohort': False, 'artifacts': [], 'tool_bytes': {}} container = None + inventory_progress = {} try: docker = runner_docker() receipt.update(docker.negotiate()) @@ -1191,7 +1203,9 @@ def collect(target, context, original, folder): receipt['tool_bytes'][role] = identity container.fence() if target in FINAL_TARGETS: - final_inventory = collect_final_inventory(container, data, file_records, receipt['tool_bytes'], policy) + final_inventory = collect_final_inventory(container, data, file_records, receipt['tool_bytes'], policy, + progress=inventory_progress) + inventory_progress.clear() for name, records in final_inventory['inventories'].items(): receipt['artifacts'].append(save(folder, 'inventories/' + name + '.json', canonical_json(records))) receipt['inventory_sha256'] = final_inventory['inventory_sha256'] @@ -1206,9 +1220,13 @@ def collect(target, context, original, folder): receipt.update({'status': 'stopped', 'reason': str(error)}) if isinstance(error, BlobRedirectDenied): receipt['redirect_rejection'] = error.diagnostics + except INVENTORY.InventoryError as error: + receipt.update({'status': 'stopped', 'reason': INVENTORY.failure_code(error)}) except BaseException: receipt.update({'status': 'stopped', 'reason': 'operation-failed-no-details-retained'}) finally: + if inventory_progress: + receipt['inventory_failure'] = dict(inventory_progress) if container is not None: try: # Reserve cleanup time after an operation deadline. No forced stop, diff --git a/tools/released_sdk_inventory.py b/tools/released_sdk_inventory.py index 0240278..d81ff7e 100644 --- a/tools/released_sdk_inventory.py +++ b/tools/released_sdk_inventory.py @@ -60,6 +60,99 @@ class InventoryError(Exception): """Only fixed failure codes; never hostile archive or daemon contents.""" +# Receipt diagnostics admit these exact source-defined codes only. Unknown +# exceptions, subclasses and payloads remain opaque even if they look like codes. +FAILURE_CODES = frozenset({ + 'build-tool-bytes-mismatch', + 'build-tool-fields', + 'build-tool-identity', + 'build-tool-path', + 'build-tool-schema', + 'file-size-limit', + 'inventory-audio-source', + 'inventory-audio-sources', + 'inventory-cares-source', + 'inventory-compiler-schema', + 'inventory-contract-schema', + 'inventory-curl-archives', + 'inventory-curl-source', + 'inventory-custom-provider', + 'inventory-custom-providers', + 'inventory-deadline', + 'inventory-directory-entry', + 'inventory-directory-mode', + 'inventory-directory-owner', + 'inventory-duplicate', + 'inventory-empty', + 'inventory-encoding', + 'inventory-end-markers', + 'inventory-extension-or-link', + 'inventory-file-not-allowed', + 'inventory-header-invalid', + 'inventory-llvm-archive-path', + 'inventory-llvm-source', + 'inventory-manifest-bytes-mismatch', + 'inventory-manifest-duplicate', + 'inventory-manifest-json', + 'inventory-manifest-missing-or-size', + 'inventory-manifest-path', + 'inventory-manifest-payloads', + 'inventory-manifest-schema', + 'inventory-manifest-size', + 'inventory-member-type', + 'inventory-metadata-binding', + 'inventory-metadata-files', + 'inventory-metadata-size', + 'inventory-owner-invalid', + 'inventory-padding', + 'inventory-parent-missing', + 'inventory-path-drift', + 'inventory-path-invalid', + 'inventory-pax-invalid', + 'inventory-provider-bytes-mismatch', + 'inventory-provider-identity', + 'inventory-provider-reader-required', + 'inventory-report-roles', + 'inventory-root-mismatch', + 'inventory-size-limit', + 'inventory-source-bytes-mismatch', + 'inventory-source-cohort', + 'inventory-source-conflict', + 'inventory-source-digest', + 'inventory-source-documents', + 'inventory-source-name', + 'inventory-source-row', + 'inventory-stream-limit', + 'inventory-tail', + 'inventory-tail-alignment', + 'inventory-truncated', + 'inventory-unframed', + 'notice-alias-cycle-or-limit', + 'notice-alias-drift', + 'notice-alias-entry', + 'notice-alias-extra-member', + 'notice-alias-stat', + 'notice-alias-target', + 'notice-boundary', + 'notice-directory-invalid', + 'notice-documents-invalid', + 'notice-mode-invalid', + 'notice-package-invalid', + 'notice-row-invalid', + 'notice-source-invalid', + 'notice-source-missing', + 'scanner-inventory-mismatch', + 'scanner-report-shape', +}) + + +def failure_code(error): + if (type(error) is InventoryError and len(error.args) == 1 + and type(error.args[0]) is str and error.args[0] in FAILURE_CODES): + return error.args[0] + return 'inventory-operation-failed' + + class ProviderReader(ABC): """Authenticated byte reader supplied by the source-inventory caller. diff --git a/tools/tests/test_collect_released_sdk_metadata.py b/tools/tests/test_collect_released_sdk_metadata.py index 9f6cbcd..91bb189 100644 --- a/tools/tests/test_collect_released_sdk_metadata.py +++ b/tools/tests/test_collect_released_sdk_metadata.py @@ -9,6 +9,7 @@ import json import os from pathlib import Path +import re import socket import tarfile import tempfile @@ -1184,6 +1185,15 @@ def test_artifact_path_links_and_overwrite_rejected(self): def test_workflow_is_same_repo_push_dispatch_read_only_and_pinned(self): text = (ROOT / M.WORKFLOW).read_text() + sdk_test_pattern = 'test*released*sdk*.py' + self.assertIn(f"-p '{sdk_test_pattern}'", text) + sdk_tests = {path.relative_to(ROOT).as_posix() + for path in (ROOT / 'tools/tests').glob(sdk_test_pattern)} + self.assertTrue(sdk_tests) + push_paths = text.split(' paths:\n', 1)[1].split(' workflow_dispatch:', 1)[0] + selected_paths = set(re.findall(r'^ - (\S+)$', push_paths, re.MULTILINE)) + self.assertFalse(sdk_tests - selected_paths, + f'SDK tests missing from push paths: {sorted(sdk_tests - selected_paths)}') self.assertNotIn(' pull_request:', text) self.assertNotIn('pull_request_target:', text) self.assertIn(' - build/llvm-sdk118-collector-refresh', text) diff --git a/tools/tests/test_released_sdk_diagnostics.py b/tools/tests/test_released_sdk_diagnostics.py new file mode 100644 index 0000000..e676fec --- /dev/null +++ b/tools/tests/test_released_sdk_diagnostics.py @@ -0,0 +1,131 @@ +# Copyright (c) 2026 Atrinik contributors +# SPDX-License-Identifier: MIT +"""Offline failure receipts retain only fixed inventory codes and stages.""" +import ast +import contextlib +import importlib.util +import json +from pathlib import Path +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location( + 'collector_diagnostic_fixtures', ROOT / 'tools/tests/test_collect_released_sdk_metadata.py') +FIXTURES = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(FIXTURES) +C = FIXTURES.M +I = C.INVENTORY +SECRET = 'PRIVATE_TOKEN https://private.invalid/archive?signature=PRIVATE_SIGNATURE' + + +class DiagnosticTests(unittest.TestCase): + def test_code_allowlist_covers_only_literal_inventory_failures(self): + codes = set() + for node in ast.walk(ast.parse((ROOT / 'tools/released_sdk_inventory.py').read_text())): + if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + and node.func.id in ('require', 'InventoryError')): + continue + value = node.args[1 if node.func.id == 'require' else 0] + if isinstance(value, ast.Constant): + self.assertIs(type(value.value), str) + codes.add(value.value) + else: + # require() alone forwards its already-selected code. + self.assertEqual(node.func.id, 'InventoryError') + self.assertEqual(ast.dump(value), "Name(id='code', ctx=Load())") + self.assertEqual(codes, I.FAILURE_CODES) + for code in codes: + self.assertEqual(I.failure_code(I.InventoryError(code)), code) + + def test_unknown_payload_types_and_exception_subclasses_never_render(self): + class Hostile: + def __str__(self): + raise AssertionError('must not stringify') + + class HostileError(I.InventoryError): + def __str__(self): + raise AssertionError('must not stringify') + + class HostileString(str): + def __hash__(self): + raise AssertionError('must not hash') + + for error in (I.InventoryError(SECRET), I.InventoryError('unknown-valid-looking-code'), + I.InventoryError(), I.InventoryError('inventory-tail', SECRET), + I.InventoryError(Hostile()), I.InventoryError(['inventory-tail']), + I.InventoryError(HostileString('inventory-tail')), + HostileError('inventory-tail'), RuntimeError(SECRET)): + with self.subTest(kind=type(error).__name__): + self.assertEqual(I.failure_code(error), 'inventory-operation-failed') + + def collect_failure(self, stage, error): + fixture = FIXTURES.OfflineTests() + data, binary = fixture.portable_fixture() + shader_path = I.ROOT + '/shader-generation.json' + shader = json.loads(data[shader_path]) + shader.update(source_commit=C.FINAL_TARGETS['portable-final']['consumer_commit'], + source_inputs={'fixture-input': '1' * 64}) + data[shader_path] = FIXTURES.encoded(shader) + operations = ( + ('producer-tree', I.InstalledInventory, 'producer', + {'sources/classic-shaders/fixture-input': {'sha256': '1' * 64}}), + ('metadata-binding', I, 'bind_metadata', None), + ('source-archives-and-providers', I, 'crosscheck_archives', None), + ('debian-notices', I.InstalledInventory, 'debian_notices', {}), + ('common-licenses', I.InstalledInventory, 'common_licenses', {}), + ('build-tools', I.InstalledInventory, 'build_tools', {}), + ('inventory-report', I, 'expected_scan', None), + ) + with contextlib.ExitStack() as stack: + mocked = [] + for name, owner, method, result in operations: + mocked.append(stack.enter_context(patch.object( + owner, method, return_value=result, side_effect=error if name == stage else None))) + scanner = stack.enter_context(patch.object(C, 'qualify_filesystem')) + result, _ = fixture.collect_portable_fixture(data, binary, target='portable-final') + scanner.assert_not_called() + index = next(i for i, operation in enumerate(operations) if operation[0] == stage) + mocked[index].assert_called_once() + for operation in mocked[index + 1:]: + operation.assert_not_called() + self.assertEqual(result['status'], 'stopped') + self.assertEqual(result['inventory_failure'], {'stage': stage}) + self.assertTrue(result['owned_container_removed']) + self.assertFalse(result['image_code_executed']) + self.assertFalse(result['final_portable_cohort']) + self.assertNotIn('inventory_sha256', result) + self.assertNotIn('metadata_sha256', result) + self.assertEqual(len(result['tool_bytes']), 12) + for secret in ('PRIVATE_TOKEN', 'private.invalid', 'PRIVATE_SIGNATURE'): + self.assertNotIn(secret, json.dumps(result)) + return result + + def test_each_inventory_stage_retains_fixed_code_and_stops_before_scanner(self): + for stage, code in ( + ('producer-tree', 'inventory-extension-or-link'), + ('metadata-binding', 'inventory-metadata-binding'), + ('source-archives-and-providers', 'inventory-provider-bytes-mismatch'), + ('debian-notices', 'notice-alias-target'), + ('common-licenses', 'file-size-limit'), + ('build-tools', 'build-tool-bytes-mismatch'), + ('inventory-report', 'inventory-report-roles'), + ): + with self.subTest(stage=stage): + receipt = self.collect_failure(stage, I.InventoryError(code)) + self.assertEqual(receipt['reason'], code) + + def test_hostile_unknown_and_noninventory_failures_remain_opaque(self): + for error, reason in ( + (I.InventoryError(SECRET), 'inventory-operation-failed'), + (I.InventoryError('unknown-code'), 'inventory-operation-failed'), + (RuntimeError(SECRET), 'operation-failed-no-details-retained'), + (KeyboardInterrupt(SECRET), 'operation-failed-no-details-retained'), + ): + with self.subTest(kind=type(error).__name__): + receipt = self.collect_failure('producer-tree', error) + self.assertEqual(receipt['reason'], reason) + + +if __name__ == '__main__': + unittest.main()