diff --git a/README.md b/README.md index 0b4a5c61..79daebb6 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,13 @@ Each build writes a diagnostic bundle into the git-tracked `diagnostic/` directo Before submitting a PR, include the generated diagnostic files in your branch. This diagnostic is required for this PR so reviewers can verify the build environment, but it can be removed before merging if requested. The repository includes a stub example in `diagnostic/` to show the expected shape; real `.logd` files are generated by `python3 build.py`. +Validate diagnostic metadata and encrypted log references with: + +```sh +python3 tools/verify_diagnostic_bundle.py diagnostic/build-00000000.json +python3 -m unittest discover -s tests -t . -v +``` + ## Bounties Active bounties are tracked as GitHub issues, find them on the Issues page. diff --git a/build.py b/build.py index 9b82104b..34970cd5 100644 --- a/build.py +++ b/build.py @@ -456,7 +456,7 @@ def build_diagnostic_report( decrypt_target = logd_relpaths[0] if logd_relpaths and len(logd_relpaths) == 1 else None if logd_relpaths and len(logd_relpaths) > 1: - decrypt_target = str((DIAGNOSTIC_DIR / f"build-{commit_id}.logd").relative_to(ROOT)) + decrypt_target = (DIAGNOSTIC_DIR / f"build-{commit_id}.logd").relative_to(ROOT).as_posix() report = { "generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), @@ -592,8 +592,8 @@ def generate_logd( safe_pw = sr.stdout.strip() logd_files = split_diagnostic_logd(logd_path) - logd_relpaths = [str(path.relative_to(ROOT)) for path in logd_files] - decrypt_target = logd_relpaths[0] if len(logd_relpaths) == 1 else str(logd_path.relative_to(ROOT)) + logd_relpaths = [path.relative_to(ROOT).as_posix() for path in logd_files] + decrypt_target = logd_relpaths[0] if len(logd_relpaths) == 1 else logd_path.relative_to(ROOT).as_posix() write_diagnostic_report( metadata_path, build_diagnostic_report( diff --git a/diagnostic/build-0e5a90ec-part001.logd b/diagnostic/build-0e5a90ec-part001.logd new file mode 100644 index 00000000..abd63176 Binary files /dev/null and b/diagnostic/build-0e5a90ec-part001.logd differ diff --git a/diagnostic/build-0e5a90ec-part002.logd b/diagnostic/build-0e5a90ec-part002.logd new file mode 100644 index 00000000..c5306146 Binary files /dev/null and b/diagnostic/build-0e5a90ec-part002.logd differ diff --git a/diagnostic/build-0e5a90ec.json b/diagnostic/build-0e5a90ec.json new file mode 100644 index 00000000..02cd4552 --- /dev/null +++ b/diagnostic/build-0e5a90ec.json @@ -0,0 +1,26 @@ +{ + "generated_at": "2026-07-06T14:00:34.952613+00:00", + "commit": "0e5a90ec", + "diagnostic_logd": [ + "diagnostic/build-0e5a90ec-part001.logd", + "diagnostic/build-0e5a90ec-part002.logd" + ], + "diagnostic_logd_error": null, + "chunked": true, + "chunk_size_bytes": 41943040, + "password": "5bd4dc9f1b40d989ade8", + "decrypt_command": "encryptly unpack diagnostic/build-0e5a90ec.logd --password 5bd4dc9f1b40d989ade8", + "total_modules": 1, + "passed": 0, + "failed": 1, + "modules": [ + { + "name": "backend", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [WinError 2] \u7cfb\u7edf\u627e\u4e0d\u5230\u6307\u5b9a\u7684\u6587\u4ef6\u3002" + } + ], + "pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic/build-0e5a90ec-part001.logd, diagnostic/build-0e5a90ec-part002.logd. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/test_verify_diagnostic_bundle.py b/tests/test_verify_diagnostic_bundle.py new file mode 100644 index 00000000..ded6d2c8 --- /dev/null +++ b/tests/test_verify_diagnostic_bundle.py @@ -0,0 +1,144 @@ +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import build +from tools.verify_diagnostic_bundle import validate_diagnostic_bundle + + +class DiagnosticBundleValidationTests(unittest.TestCase): + def _write_metadata(self, root: Path, name: str, diagnostic_logd): + metadata = root / "diagnostic" / name + metadata.parent.mkdir(parents=True, exist_ok=True) + metadata.write_text( + json.dumps( + { + "commit": "12345678", + "diagnostic_logd": diagnostic_logd, + "modules": [], + } + ), + encoding="utf-8", + ) + return metadata + + def test_valid_metadata_matches_existing_repo_relative_logd(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + (root / "diagnostic").mkdir() + (root / "diagnostic" / "build-12345678.logd").write_bytes(b"logd") + metadata = self._write_metadata( + root, + "build-12345678.json", + "diagnostic/build-12345678.logd", + ) + + self.assertEqual(validate_diagnostic_bundle(metadata, root), []) + + def test_chunked_logd_references_are_allowed(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + diagnostic = root / "diagnostic" + diagnostic.mkdir() + (diagnostic / "build-12345678-part001.logd").write_bytes(b"one") + (diagnostic / "build-12345678-part002.logd").write_bytes(b"two") + metadata = self._write_metadata( + root, + "build-12345678.json", + [ + "diagnostic/build-12345678-part001.logd", + "diagnostic/build-12345678-part002.logd", + ], + ) + + self.assertEqual(validate_diagnostic_bundle(metadata, root), []) + + def test_missing_json_fails_clearly(self): + with tempfile.TemporaryDirectory() as tmpdir: + errors = validate_diagnostic_bundle(Path(tmpdir) / "diagnostic" / "missing.json", Path(tmpdir)) + + self.assertTrue(any("missing diagnostic JSON" in error for error in errors)) + + def test_missing_logd_reference_fails_clearly(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + metadata = self._write_metadata( + root, + "build-12345678.json", + "diagnostic/build-12345678.logd", + ) + + errors = validate_diagnostic_bundle(metadata, root) + + self.assertTrue(any("referenced .logd artifact is missing" in error for error in errors)) + + def test_mismatched_logd_pair_fails_clearly(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + diagnostic = root / "diagnostic" + diagnostic.mkdir() + (diagnostic / "build-deadbeef.logd").write_bytes(b"logd") + metadata = self._write_metadata( + root, + "build-12345678.json", + "diagnostic/build-deadbeef.logd", + ) + + errors = validate_diagnostic_bundle(metadata, root) + + self.assertTrue(any("JSON/logd pair mismatch" in error for error in errors)) + + def test_absolute_or_windows_paths_are_rejected(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + metadata = self._write_metadata( + root, + "build-12345678.json", + r"C:\Users\someone\diagnostic\build-12345678.logd", + ) + + errors = validate_diagnostic_bundle(metadata, root) + + self.assertTrue(any("repository-relative / path" in error for error in errors)) + + def test_local_path_and_user_values_are_redacted(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + diagnostic = root / "diagnostic" + diagnostic.mkdir() + (diagnostic / "build-12345678.logd").write_bytes(b"logd") + metadata = self._write_metadata( + root, + "build-12345678.json", + "diagnostic/build-12345678.logd", + ) + payload = json.loads(metadata.read_text(encoding="utf-8")) + payload["output"] = f"leaked path {root}" + metadata.write_text(json.dumps(payload), encoding="utf-8") + + with mock.patch("tools.verify_diagnostic_bundle.getpass.getuser", return_value="example-user"): + with mock.patch("tools.verify_diagnostic_bundle.platform.node", return_value="example-host"): + errors = validate_diagnostic_bundle(metadata, root) + + self.assertTrue(any("leaks local value" in error for error in errors)) + + def test_build_report_uses_posix_reassemble_target_for_chunked_logd(self): + report = build.build_diagnostic_report( + results=[], + commit_id="12345678", + logd_relpaths=[ + "diagnostic/build-12345678-part001.logd", + "diagnostic/build-12345678-part002.logd", + ], + password="pw", + chunked=True, + ) + + self.assertIn("diagnostic/build-12345678.logd", report["decrypt_command"]) + self.assertNotIn("\\", report["decrypt_command"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/verify_diagnostic_bundle.py b/tools/verify_diagnostic_bundle.py new file mode 100644 index 00000000..cf552db3 --- /dev/null +++ b/tools/verify_diagnostic_bundle.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import getpass +import json +import os +import platform +import tempfile +from pathlib import Path, PurePosixPath +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] + + +def _as_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + return [value] + if isinstance(value, list) and all(isinstance(item, str) for item in value): + return value + return [] + + +def _is_repo_relative_posix_path(value: str) -> bool: + if "\\" in value: + return False + path = PurePosixPath(value) + return not path.is_absolute() and ".." not in path.parts and value.startswith("diagnostic/") + + +def _sensitive_tokens(root: Path) -> list[str]: + candidates = { + str(root), + str(root.resolve()), + str(Path.home()), + tempfile.gettempdir(), + getpass.getuser(), + platform.node(), + } + return sorted({token for token in candidates if token and len(token) >= 3}, key=len, reverse=True) + + +def validate_diagnostic_bundle(metadata_path: Path, root: Path = ROOT) -> list[str]: + errors: list[str] = [] + if not metadata_path.exists(): + return [f"missing diagnostic JSON: {metadata_path}"] + + try: + report = json.loads(metadata_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return [f"invalid diagnostic JSON: {exc}"] + + logd_paths = _as_list(report.get("diagnostic_logd")) + if not logd_paths: + errors.append("diagnostic_logd is missing or not a string/list") + + for logd_path in logd_paths: + if not _is_repo_relative_posix_path(logd_path): + errors.append(f"diagnostic_logd must be a repository-relative / path: {logd_path}") + continue + absolute_logd = root / Path(logd_path) + if not absolute_logd.exists(): + errors.append(f"referenced .logd artifact is missing: {logd_path}") + if absolute_logd.suffix != ".logd": + errors.append(f"referenced diagnostic artifact must end with .logd: {logd_path}") + + expected_prefix = metadata_path.stem + for logd_path in logd_paths: + if Path(logd_path).stem != expected_prefix and not Path(logd_path).stem.startswith(f"{expected_prefix}-part"): + errors.append(f"diagnostic JSON/logd pair mismatch: {metadata_path.name} -> {logd_path}") + + serialized = json.dumps(report, sort_keys=True) + for token in _sensitive_tokens(root): + escaped_token = json.dumps(token)[1:-1] + if token in serialized or escaped_token in serialized: + errors.append(f"diagnostic metadata leaks local value: {token}") + + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate diagnostic JSON/logd bundle pairing") + parser.add_argument("metadata", type=Path, help="Path to diagnostic/build-*.json") + args = parser.parse_args() + + errors = validate_diagnostic_bundle(args.metadata) + if errors: + for error in errors: + print(f"ERROR: {error}") + return 1 + print(f"OK: {args.metadata}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())