diff --git a/build.py b/build.py index 07b97c41..3b0d873e 100644 --- a/build.py +++ b/build.py @@ -9,6 +9,7 @@ import shutil import subprocess import sys +import tempfile import time from dataclasses import dataclass from pathlib import Path @@ -169,6 +170,7 @@ class Module: "linux-x64": ENCRYPTLY_DIR / "linux-x64" / "encryptly", "linux-arm64": ENCRYPTLY_DIR / "linux-arm64" / "encryptly", "macos-arm64": ENCRYPTLY_DIR / "macos-arm64" / "encryptly", + "macos-x64": ENCRYPTLY_DIR / "macos-x64" / "encryptly", "windows-x64": ENCRYPTLY_DIR / "windows-x64" / "encryptly.exe", "windows-arm64": ENCRYPTLY_DIR / "windows-arm64" / "encryptly.exe", } @@ -277,6 +279,83 @@ def color(text: str, code: str) -> str: return text return f"{code}{text}{Colors.RESET}" + +def repo_relative(path: Path) -> str: + """Return a repository-relative path with POSIX separators for metadata.""" + return path.relative_to(ROOT).as_posix() + + +def _redaction_values() -> list[tuple[str, str]]: + values: list[tuple[str, str]] = [] + candidates = [ + (str(ROOT), ""), + (str(Path.home()), ""), + (tempfile.gettempdir(), ""), + (getpass.getuser(), ""), + (platform.node(), ""), + ] + for raw, replacement in candidates: + if raw: + values.append((raw, replacement)) + if os.sep != "/": + values.append((raw.replace(os.sep, "/"), replacement)) + return sorted(values, key=lambda item: len(item[0]), reverse=True) + + +def redact_metadata_text(value: str) -> str: + redacted = value + for needle, replacement in _redaction_values(): + redacted = redacted.replace(needle, replacement) + return redacted + + +def metadata_path_value(value: Optional[str]) -> Optional[str]: + """Normalize artifact paths for JSON metadata without leaking local paths.""" + if value is None: + return None + try: + path = Path(value).resolve() + return repo_relative(path) + except Exception: + return redact_metadata_text(value) + + +def validate_diagnostic_metadata(metadata_path: Path, root: Path = ROOT) -> list[str]: + """Return clear validation errors for diagnostic JSON/logd pairing.""" + errors: list[str] = [] + if not metadata_path.exists(): + return [f"diagnostic metadata is missing: {metadata_path}"] + + try: + report = json.loads(metadata_path.read_text(encoding="utf-8")) + except Exception as exc: + return [f"diagnostic metadata is not valid JSON: {exc}"] + + diagnostic_logd = report.get("diagnostic_logd") + if not diagnostic_logd: + if report.get("diagnostic_logd_error"): + return errors + return ["diagnostic metadata is missing diagnostic_logd"] + + logd_entries = diagnostic_logd if isinstance(diagnostic_logd, list) else [diagnostic_logd] + for entry in logd_entries: + if not isinstance(entry, str): + errors.append(f"diagnostic_logd entry is not a string: {entry!r}") + continue + if "\\" in entry: + errors.append(f"diagnostic_logd must use '/' separators: {entry}") + if Path(entry).is_absolute(): + errors.append(f"diagnostic_logd must be repository-relative: {entry}") + continue + logd_path = root / entry + if not logd_path.exists(): + errors.append(f"diagnostic .logd artifact is missing: {entry}") + if logd_path.suffix != ".logd": + errors.append(f"diagnostic artifact is not a .logd file: {entry}") + + return errors + + def check_prerequisites() -> list[str]: required = { "cargo": "Rust", @@ -500,7 +579,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 = repo_relative(DIAGNOSTIC_DIR / f"build-{commit_id}.logd") report = { "generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), @@ -523,8 +602,8 @@ def build_diagnostic_report( "name": name, "status": "PASS" if success else "FAIL", "elapsed_seconds": round(elapsed, 3), - "artifact": binary, - "output": output, + "artifact": metadata_path_value(binary), + "output": redact_metadata_text(output), } for name, success, elapsed, output, binary in results ], @@ -539,7 +618,7 @@ def build_diagnostic_report( def write_diagnostic_report(metadata_path: Path, report: dict) -> None: metadata_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") - print(f" {color('✓', Colors.GREEN)} {metadata_path.relative_to(ROOT)} created") + print(f" {color('✓', Colors.GREEN)} {repo_relative(metadata_path)} created") def commit_diagnostic_artifacts(paths: list[Path], commit_id: str) -> bool: @@ -549,7 +628,7 @@ def commit_diagnostic_artifacts(paths: list[Path], commit_id: str) -> bool: print(f" {color('✗', Colors.RED)} No diagnostic artifacts found to commit") return False - relpaths = [str(path.relative_to(ROOT)) for path in existing] + relpaths = [repo_relative(path) for path in existing] status = subprocess.run( ["git", "status", "--porcelain", "--", *relpaths], cwd=str(ROOT), @@ -703,8 +782,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 = [repo_relative(path) for path in logd_files] + decrypt_target = logd_relpaths[0] if len(logd_relpaths) == 1 else repo_relative(logd_path) write_diagnostic_report( metadata_path, build_diagnostic_report( @@ -719,7 +798,7 @@ def generate_logd( for path in logd_files: size_kb = path.stat().st_size / 1024.0 print( - f" {color('✓', Colors.GREEN)} {path.relative_to(ROOT)} created " + f" {color('✓', Colors.GREEN)} {repo_relative(path)} created " f"({size_kb:.1f} KiB)" ) if len(logd_files) > 1: @@ -737,7 +816,7 @@ def generate_logd( print(f" diagnostic log file(s) and metadata file with this password.") if len(logd_files) > 1: print(f" Reassemble chunks in order before unpacking:") - print(f" cat {' '.join(logd_relpaths)} > {logd_path.relative_to(ROOT)}") + print(f" cat {' '.join(logd_relpaths)} > {repo_relative(logd_path)}") print(f" {color(safe_pw, Colors.CYAN)}") print(f" {color(f'encryptly unpack {decrypt_target} --password {safe_pw}', Colors.GRAY)}") return True diff --git a/diagnostic/build-fc78a64d.json b/diagnostic/build-fc78a64d.json new file mode 100644 index 00000000..b6e219d7 --- /dev/null +++ b/diagnostic/build-fc78a64d.json @@ -0,0 +1,87 @@ +{ + "generated_at": "2026-07-08T11:45:48.455656+00:00", + "commit": "fc78a64d", + "diagnostic_logd": "diagnostic/build-fc78a64d.logd", + "diagnostic_logd_error": null, + "message_blocker": null, + "chunked": false, + "chunk_size_bytes": null, + "password": "4f9406258c55071a2684", + "decrypt_command": "encryptly unpack diagnostic/build-fc78a64d.logd --password 4f9406258c55071a2684", + "total_modules": 10, + "passed": 2, + "failed": 8, + "modules": [ + { + "name": "backend", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'cargo'" + }, + { + "name": "frontend", + "status": "PASS", + "elapsed_seconds": 14.831, + "artifact": "frontend/dist", + "output": "> tent-frontend@0.0.0 build\n> tsc -b && vite build\n\nvite v6.4.3 building for production...\ntransforming...\n\u2713 100 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.62 kB \u2502 gzip: 0.34 kB\ndist/assets/state-BkjSKDbY.js 8.91 kB \u2502 gzip: 3.55 kB \u2502 map: 57.15 kB\ndist/assets/vendor-CREcWLHI.js 48.93 kB \u2502 gzip: 17.22 kB \u2502 map: 481.27 kB\ndist/assets/index-CyxcoTyU.js 231.32 kB \u2502 gzip: 72.02 kB \u2502 map: 1,044.42 kB\n\u2713 built in 2.04s" + }, + { + "name": "market", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'go'" + }, + { + "name": "frailbox", + "status": "FAIL", + "elapsed_seconds": 1.258, + "artifact": null, + "output": "gcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/arena.c -o build/src/arena.o\nsrc/arena.c:17:23: error: use of undeclared identifier 'MAP_HUGETLB'\n 17 | mmap_flags |= MAP_HUGETLB;\n | ^~~~~~~~~~~\nsrc/arena.c:179:17: warning: comparison of distinct pointer types ('const void *' and 'char *') [-Wcompare-distinct-pointer-types]\n 179 | ptr < (char *)region->start + region->size) {\n | ~~~ ^ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n1 warning and 1 error generated.\nmake: *** [build/src/arena.o] Error 1" + }, + { + "name": "engine", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'cmake'" + }, + { + "name": "compliance", + "status": "FAIL", + "elapsed_seconds": 0.039, + "artifact": null, + "output": "The operation couldn\u2019t be completed. Unable to locate a Java Runtime.\nPlease visit http://www.java.com for information on installing Java." + }, + { + "name": "v2-market-stream", + "status": "PASS", + "elapsed_seconds": 0.329, + "artifact": null, + "output": "Syntax OK" + }, + { + "name": "nfc-scanner", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'luac'" + }, + { + "name": "openapi-haskell", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'ghc'" + }, + { + "name": "openapi-tools", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'luac'" + } + ], + "pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic/build-fc78a64d.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/diagnostic/build-fc78a64d.logd b/diagnostic/build-fc78a64d.logd new file mode 100644 index 00000000..390418a0 Binary files /dev/null and b/diagnostic/build-fc78a64d.logd differ diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 00000000..bb38ec17 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,123 @@ +import getpass +import json +import platform +import tempfile +import unittest +from pathlib import Path + +import build + + +class DiagnosticMetadataTests(unittest.TestCase): + def test_report_redacts_local_identity_and_normalizes_artifact_paths(self): + repo_artifact = build.ROOT / "backend" / "target" / "debug" / "backend" + output = "\n".join( + [ + f"repo={build.ROOT}", + f"home={Path.home()}", + f"temp={tempfile.gettempdir()}", + f"user={getpass.getuser()}", + f"host={platform.node()}", + ] + ) + + report = build.build_diagnostic_report( + [("backend", True, 1.234, output, str(repo_artifact))], + "deadbeef", + logd_relpaths=["diagnostic/build-deadbeef.logd"], + password="pw", + ) + + module = report["modules"][0] + self.assertEqual(module["artifact"], "backend/target/debug/backend") + self.assertNotIn("\\", module["artifact"]) + + encoded = json.dumps(report) + self.assertNotIn(str(build.ROOT), encoded) + self.assertNotIn(str(Path.home()), encoded) + self.assertNotIn(tempfile.gettempdir(), encoded) + self.assertNotIn(getpass.getuser(), encoded) + if platform.node(): + self.assertNotIn(platform.node(), encoded) + + def test_validate_diagnostic_metadata_accepts_matching_logd_pair(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + diagnostic_dir = root / "diagnostic" + diagnostic_dir.mkdir() + (diagnostic_dir / "build-deadbeef.logd").write_bytes(b"encrypted") + metadata_path = diagnostic_dir / "build-deadbeef.json" + metadata_path.write_text( + json.dumps({"diagnostic_logd": "diagnostic/build-deadbeef.logd"}), + encoding="utf-8", + ) + + self.assertEqual(build.validate_diagnostic_metadata(metadata_path, root), []) + + def test_validate_diagnostic_metadata_reports_missing_json(self): + missing = Path(tempfile.gettempdir()) / "does-not-exist-diagnostic.json" + + errors = build.validate_diagnostic_metadata(missing, missing.parent) + + self.assertEqual(len(errors), 1) + self.assertIn("metadata is missing", errors[0]) + + def test_validate_diagnostic_metadata_reports_missing_logd(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + diagnostic_dir = root / "diagnostic" + diagnostic_dir.mkdir() + metadata_path = diagnostic_dir / "build-deadbeef.json" + metadata_path.write_text( + json.dumps({"diagnostic_logd": "diagnostic/build-deadbeef.logd"}), + encoding="utf-8", + ) + + errors = build.validate_diagnostic_metadata(metadata_path, root) + + self.assertEqual(len(errors), 1) + self.assertIn(".logd artifact is missing", errors[0]) + + def test_validate_diagnostic_metadata_rejects_absolute_or_backslash_paths(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + diagnostic_dir = root / "diagnostic" + diagnostic_dir.mkdir() + metadata_path = diagnostic_dir / "build-deadbeef.json" + metadata_path.write_text( + json.dumps( + { + "diagnostic_logd": [ + str(root / "diagnostic" / "build-deadbeef.logd"), + "diagnostic\\build-deadbeef.logd", + ] + } + ), + encoding="utf-8", + ) + + errors = build.validate_diagnostic_metadata(metadata_path, root) + + self.assertTrue(any("repository-relative" in error for error in errors)) + self.assertTrue(any("'/' separators" in error for error in errors)) + + def test_validate_diagnostic_metadata_rejects_mismatched_artifact_extension(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + diagnostic_dir = root / "diagnostic" + diagnostic_dir.mkdir() + (diagnostic_dir / "build-deadbeef.txt").write_text("not logd", encoding="utf-8") + metadata_path = diagnostic_dir / "build-deadbeef.json" + metadata_path.write_text( + json.dumps({"diagnostic_logd": "diagnostic/build-deadbeef.txt"}), + encoding="utf-8", + ) + + errors = build.validate_diagnostic_metadata(metadata_path, root) + + self.assertEqual(len(errors), 1) + self.assertIn("not a .logd", errors[0]) + + +if __name__ == "__main__": + unittest.main()