diff --git a/src/local_transcriber/benchmark_experiment.py b/src/local_transcriber/benchmark_experiment.py index 8c8ac91..84bc07e 100644 --- a/src/local_transcriber/benchmark_experiment.py +++ b/src/local_transcriber/benchmark_experiment.py @@ -879,7 +879,10 @@ def environment_snapshot() -> dict[str, Any]: def validate_environment( - manifest: Mapping[str, Any], environment: Mapping[str, Any] + manifest: Mapping[str, Any], + environment: Mapping[str, Any], + *, + check_machine: bool = True, ) -> None: experiment = manifest["experiment"] for name, expected in experiment["dependencies"].items(): @@ -898,6 +901,8 @@ def validate_environment( } if expected_builds != {runtime_build}: raise ValueError("Хеш runtime-сборки не совпал с enabled-ячейками") + if not check_machine: + return machine = manifest["machine"] if environment.get("platform") != machine["os"]: raise ValueError("ОС не совпала с manifest") @@ -946,7 +951,9 @@ def _runtime_profile() -> str | None: except (OSError, subprocess.TimeoutExpired): return None value = completed.stdout.strip() - return value or None if completed.returncode == 0 else None + if completed.returncode != 0: + return None + return value or None def _runtime_governor() -> str | None: @@ -1640,6 +1647,32 @@ def worker_main(request_path: Path) -> None: _save_json(Path(request["response_path"]), result) +def _manifest_file_index(manifest: Mapping[str, Any]) -> dict[str, str]: + """Возвращает каждый входной файл, который manifest требует от капсулы.""" + required = {} + + def add(item: Mapping[str, Any], label: str) -> None: + path = _require_relative_path(item.get("path"), f"{label}.path") + sha256 = _require_sha256(item.get("sha256"), f"{label}.sha256") + previous = required.setdefault(path, sha256) + if previous != sha256: + raise ValueError(f"{label}: один путь ссылается на разные SHA-256") + + for artifact in manifest.get("artifacts", []): + add(artifact, f"artifact:{artifact.get('id')}") + for recording in manifest.get("recordings", []): + label = f"recording:{recording.get('id')}" + add(recording, label) + for key in ("reference", "asr_words"): + if (side := recording.get(key)) is not None: + add(side, f"{label}.{key}") + for source in manifest.get("calibration", []): + add(source, f"calibration:{source.get('id')}") + if not required: + raise ValueError("Manifest капсулы не содержит входных файлов") + return required + + def verify_capsule( archive_path: Path, expected_sha256: str, @@ -1708,6 +1741,11 @@ def verify_capsule( if declared["manifest.json"] != manifest_sha256: raise ValueError("Хеш manifest.json не совпал с индексом капсулы") archived_manifest_value = json.loads(archived_manifest) + for name, expected in _manifest_file_index(archived_manifest_value).items(): + if declared.get(name) != expected: + raise ValueError( + "Входной файл manifest не совпал с индексом файлов капсулы" + ) handoff = archived_manifest_value.get("handoff", {}) for candidate in handoff.get("candidate_recipes", []): result_file = candidate.get("result_file") @@ -1738,8 +1776,6 @@ def run_cli(args: Any, raw_manifest: Mapping[str, Any]) -> None: ) manifest = resolve_manifest_paths(raw_manifest, args.manifest.parent) warnings = validate_manifest(manifest) - environment = environment_snapshot() - validate_environment(manifest, environment) capsule_path = getattr(args, "capsule", None) capsule_sha256 = getattr(args, "capsule_sha256", None) finalized_handoff = manifest["handoff"]["outcome"] != "pending" @@ -1755,6 +1791,15 @@ def run_cli(args: Any, raw_manifest: Mapping[str, Any]) -> None: expected_report_sha256=manifest["handoff"]["public_report_sha256"], expected_capsule_id=manifest["handoff"]["capsule_id"], ) + environment = environment_snapshot() + portable_handoff_check = ( + args.validate_only and finalized_handoff and capsule_result is not None + ) + validate_environment( + manifest, + environment, + check_machine=not portable_handoff_check, + ) if args.validate_only: plan = safe_plan(manifest, warnings, environment) if capsule_result is not None: diff --git a/tests/test_benchmark_experiment.py b/tests/test_benchmark_experiment.py index fb1ed26..aa7b12f 100644 --- a/tests/test_benchmark_experiment.py +++ b/tests/test_benchmark_experiment.py @@ -294,6 +294,34 @@ def test_identity_and_runtime_validation_cover_exact_versions_and_build(tmp_path experiment.validate_environment(manifest, changed_build) +def test_portable_handoff_check_skips_only_machine_identity(tmp_path): + manifest = _manifest(tmp_path) + foreign_environment = copy.deepcopy(_environment()) + foreign_environment.update( + platform="Linux Ryzen", + logical_cores=24, + physical_cores=12, + cpu_sku="AMD Ryzen 9 5900X 12-Core Processor", + cpu_flags=["avx", "avx2", "avx512f"], + ram_bytes=64 * 1024**3, + swap_total_bytes=0, + ) + + experiment.validate_environment( + manifest, + foreign_environment, + check_machine=False, + ) + + foreign_environment["packages"]["numpy"] = "2.4.4" + with pytest.raises(ValueError, match="numpy==2.4.3"): + experiment.validate_environment( + manifest, + foreign_environment, + check_machine=False, + ) + + def test_manifest_rejects_stage_boundary_thread_budget_and_non_alternating_ab(tmp_path): manifest = _manifest(tmp_path) manifest["cells"][0]["stage"] = "ryzen" @@ -733,7 +761,19 @@ def test_capsule_verification_checks_hashes_commits_and_safe_paths(tmp_path): payload = b"private" payload_hash = hashlib.sha256(payload).hexdigest() manifest_path = tmp_path / "manifest.json" - manifest_bytes = b'{"schema_version":3}\n' + manifest_value = { + "schema_version": 3, + "artifacts": [ + { + "id": "input", + "path": "data/payload.bin", + "sha256": payload_hash, + } + ], + "recordings": [], + "calibration": [], + } + manifest_bytes = json.dumps(manifest_value).encode() manifest_path.write_bytes(manifest_bytes) capsule = { "capsule_id": "opaque-41", @@ -768,6 +808,19 @@ def test_capsule_verification_checks_hashes_commits_and_safe_paths(tmp_path): "manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(), } + incomplete_capsule = copy.deepcopy(capsule) + incomplete_capsule["files"].pop("data/payload.bin") + incomplete_path = tmp_path / "incomplete.zip" + with zipfile.ZipFile(incomplete_path, "w") as archive: + archive.writestr("capsule.json", json.dumps(incomplete_capsule)) + archive.writestr("manifest.json", manifest_bytes) + with pytest.raises(ValueError, match="Входной файл manifest"): + experiment.verify_capsule( + incomplete_path, + experiment.file_sha256(incomplete_path), + expected_manifest_path=manifest_path, + ) + unrelated_manifest = tmp_path / "unrelated-manifest.json" unrelated_manifest.write_bytes(b'{"schema_version":3,"other":true}\n') with pytest.raises(ValueError, match="не совпал с manifest.json капсулы"): @@ -791,9 +844,16 @@ def test_capsule_verification_checks_hashes_commits_and_safe_paths(tmp_path): def test_capsule_binds_candidate_result_evidence_to_file_index(tmp_path): + input_bytes = b"input" + input_hash = hashlib.sha256(input_bytes).hexdigest() result_bytes = b'{"aggregate":{"quality_passed":true}}' result_hash = hashlib.sha256(result_bytes).hexdigest() manifest_value = { + "artifacts": [ + {"id": "input", "path": "data/input.bin", "sha256": input_hash} + ], + "recordings": [], + "calibration": [], "handoff": { "candidate_recipes": [ { @@ -812,6 +872,7 @@ def test_capsule_binds_candidate_result_evidence_to_file_index(tmp_path): "public_report_sha256": "4" * 64, "files": { "manifest.json": hashlib.sha256(manifest_bytes).hexdigest(), + "data/input.bin": input_hash, "results/candidate.json": result_hash, }, } @@ -819,6 +880,7 @@ def test_capsule_binds_candidate_result_evidence_to_file_index(tmp_path): with zipfile.ZipFile(archive_path, "w") as archive: archive.writestr("capsule.json", json.dumps(capsule)) archive.writestr("manifest.json", manifest_bytes) + archive.writestr("data/input.bin", input_bytes) archive.writestr("results/candidate.json", result_bytes) experiment.verify_capsule( @@ -843,6 +905,7 @@ def test_capsule_binds_candidate_result_evidence_to_file_index(tmp_path): with zipfile.ZipFile(broken_path, "w") as archive: archive.writestr("capsule.json", json.dumps(capsule)) archive.writestr("manifest.json", broken_manifest_bytes) + archive.writestr("data/input.bin", input_bytes) archive.writestr("results/candidate.json", result_bytes) with pytest.raises(ValueError, match="Результат кандидата"): experiment.verify_capsule(