|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Exercise the installed SDK audit tool against real macOS Mach-O binaries.""" |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import json |
| 6 | +import os |
| 7 | +from pathlib import Path |
| 8 | +import platform |
| 9 | +import resource |
| 10 | +import subprocess |
| 11 | +import sys |
| 12 | +import tempfile |
| 13 | +import time |
| 14 | + |
| 15 | + |
| 16 | +ROOT = Path(__file__).resolve().parents[2] |
| 17 | + |
| 18 | + |
| 19 | +def run(arguments, **kwargs): |
| 20 | + return subprocess.run(arguments, check=True, text=True, **kwargs) |
| 21 | + |
| 22 | + |
| 23 | +def main(): |
| 24 | + if sys.platform != "darwin": |
| 25 | + print("installed macOS bundle audit: skipped (requires macOS)") |
| 26 | + return 0 |
| 27 | + architecture = platform.machine() |
| 28 | + if architecture not in ("arm64", "x86_64"): |
| 29 | + raise RuntimeError(f"unsupported test architecture: {architecture}") |
| 30 | + with tempfile.TemporaryDirectory() as temporary: |
| 31 | + work = Path(temporary) |
| 32 | + build = work / "sdk-build" |
| 33 | + prefix = work / "sdk" |
| 34 | + run([ |
| 35 | + "cmake", "-S", str(ROOT / "src/WebScene.Sdk"), "-B", str(build), |
| 36 | + "-DWEBSCENE_SDK_TOOLS_ONLY=ON", f"-DCMAKE_INSTALL_PREFIX={prefix}", |
| 37 | + ], stdout=subprocess.DEVNULL) |
| 38 | + run(["cmake", "--install", str(build)], stdout=subprocess.DEVNULL) |
| 39 | + tool = prefix / "share/webscene/tools/audit_macos_bundle.py" |
| 40 | + schema_path = prefix / "share/webscene/schemas/webscene-macho-audit.schema.json" |
| 41 | + package_tool = prefix / "share/webscene/tools/package_macos.py" |
| 42 | + for installed in (tool, schema_path, package_tool): |
| 43 | + if not installed.is_file(): |
| 44 | + relative = installed.relative_to(prefix) |
| 45 | + raise RuntimeError(f"installed SDK surface is missing {relative}") |
| 46 | + |
| 47 | + bundle = work / "Fixture.app" |
| 48 | + macos = bundle / "Contents/MacOS" |
| 49 | + frameworks = bundle / "Contents/Frameworks" |
| 50 | + plugins = bundle / "Contents/PlugIns" |
| 51 | + for directory in (macos, frameworks, plugins): |
| 52 | + directory.mkdir(parents=True) |
| 53 | + source = work / "fixture.c" |
| 54 | + source.write_text("int fixture_value(void) { return 0; }\n") |
| 55 | + host_source = work / "host.c" |
| 56 | + host_source.write_text( |
| 57 | + "extern int fixture_value(void); int main(void) { return fixture_value(); }\n" |
| 58 | + ) |
| 59 | + extension_source = work / "extension.c" |
| 60 | + extension_source.write_text( |
| 61 | + "extern int fixture_value(void); " |
| 62 | + "int extension_entry(void) { return fixture_value(); }\n" |
| 63 | + ) |
| 64 | + library = frameworks / "libfixture.dylib" |
| 65 | + host = macos / "Fixture" |
| 66 | + extension = plugins / "sample.node" |
| 67 | + common = ["-arch", architecture, "-mmacosx-version-min=13.0"] |
| 68 | + run(["xcrun", "clang", *common, "-dynamiclib", str(source), |
| 69 | + "-install_name", "@rpath/libfixture.dylib", "-o", str(library)]) |
| 70 | + run(["xcrun", "clang", *common, str(host_source), str(library), |
| 71 | + "-Wl,-rpath,@executable_path/../Frameworks", "-o", str(host)]) |
| 72 | + run(["xcrun", "clang", *common, "-bundle", str(extension_source), str(library), |
| 73 | + "-Wl,-rpath,@loader_path/../Frameworks", "-o", str(extension)]) |
| 74 | + |
| 75 | + evidence_path = work / "evidence.json" |
| 76 | + command = [ |
| 77 | + sys.executable, str(tool), "--bundle", str(bundle), |
| 78 | + "--executable", "Contents/MacOS/Fixture", |
| 79 | + "--architecture", architecture, |
| 80 | + "--maximum-deployment-target", "15.0", |
| 81 | + "--maximum-evidence-bytes", str(64 * 1024), |
| 82 | + "--evidence", str(evidence_path), |
| 83 | + ] |
| 84 | + before_fds = len(list(Path("/dev/fd").iterdir())) |
| 85 | + before_rss = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss |
| 86 | + started = time.monotonic() |
| 87 | + expected = None |
| 88 | + for _ in range(10): |
| 89 | + run(command, stdout=subprocess.DEVNULL) |
| 90 | + payload = evidence_path.read_bytes() |
| 91 | + if expected is None: |
| 92 | + expected = payload |
| 93 | + elif payload != expected: |
| 94 | + raise RuntimeError("installed audit evidence is not deterministic") |
| 95 | + elapsed = time.monotonic() - started |
| 96 | + after_rss = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss |
| 97 | + after_fds = len(list(Path("/dev/fd").iterdir())) |
| 98 | + evidence = json.loads(expected) |
| 99 | + schema = json.loads(schema_path.read_text()) |
| 100 | + if schema["properties"]["schemaVersion"]["const"] != evidence["schemaVersion"]: |
| 101 | + raise RuntimeError("installed evidence and schema versions differ") |
| 102 | + if evidence["summary"]["machoFiles"] != 3: |
| 103 | + raise RuntimeError("installed tool did not recursively audit all three Mach-O files") |
| 104 | + if not any(item["path"].endswith(".node") for item in evidence["binaries"]): |
| 105 | + raise RuntimeError("installed tool omitted the native extension") |
| 106 | + if os.fsencode(temporary) in expected: |
| 107 | + raise RuntimeError("installed evidence contains an absolute temporary path") |
| 108 | + if elapsed >= 10.0: |
| 109 | + raise RuntimeError(f"ten installed audits exceeded 10 seconds: {elapsed:.3f}s") |
| 110 | + if after_fds > before_fds + 2: |
| 111 | + raise RuntimeError( |
| 112 | + f"installed audits leaked file descriptors: {before_fds} -> {after_fds}" |
| 113 | + ) |
| 114 | + if max(0, after_rss - before_rss) > 128 * 1024 * 1024: |
| 115 | + raise RuntimeError("installed audit child RSS grew by more than 128 MiB") |
| 116 | + if len(expected) >= 64 * 1024: |
| 117 | + raise RuntimeError("installed audit evidence exceeded 64 KiB") |
| 118 | + |
| 119 | + run(["xcrun", "install_name_tool", "-change", "@rpath/libfixture.dylib", |
| 120 | + "@rpath/missing.dylib", str(extension)]) |
| 121 | + rejected = subprocess.run(command, text=True, stdout=subprocess.PIPE, |
| 122 | + stderr=subprocess.STDOUT, check=False) |
| 123 | + if rejected.returncode == 0 or "unresolved bundled dependency" not in rejected.stdout: |
| 124 | + raise RuntimeError("installed tool accepted a dangling native-extension dependency") |
| 125 | + print( |
| 126 | + f"installed macOS bundle audit: 3 Mach-O files, 10 stable passes in " |
| 127 | + f"{elapsed:.3f}s, {len(expected)} evidence bytes" |
| 128 | + ) |
| 129 | + return 0 |
| 130 | + |
| 131 | + |
| 132 | +if __name__ == "__main__": |
| 133 | + raise SystemExit(main()) |
0 commit comments