diff --git a/scripts/nix/test-trace-ghc-sigbus.py b/scripts/nix/test-trace-ghc-sigbus.py new file mode 100644 index 0000000000..6c78a464df --- /dev/null +++ b/scripts/nix/test-trace-ghc-sigbus.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Owned native Darwin controls; cross-compile x86_64 without executing it.""" + +import importlib.util +import hashlib +import json +import os +from pathlib import Path +import platform +import signal +import struct +import subprocess +import tempfile +import unittest +from unittest import mock + + +HERE = Path(__file__).resolve().parent +SPEC = importlib.util.spec_from_file_location("sigbus", HERE / "trace-ghc-sigbus.py") +SIGBUS = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(SIGBUS) + +FIXTURE = r''' +#define _DARWIN_C_SOURCE 1 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int environment_ok(const char *expected) +{ + if (getenv("SIMPLEX_GHC_BUS_RECORD") != NULL || + getenv("SIMPLEX_GHC_BUS_DYLD_PRESENT") != NULL || + getenv("SIMPLEX_GHC_BUS_DYLD_VALUE") != NULL) return 0; + const char *value = getenv("DYLD_INSERT_LIBRARIES"); + return strcmp(expected, "") == 0 ? value == NULL + : value != NULL && strcmp(expected, value) == 0; +} + +int main(int argc, char **argv) +{ + if (argc < 2) return 120; + if (strcmp(argv[1], "0") == 0 || strcmp(argv[1], "7") == 0) { + printf("argv0=%s argc=%d last=%s\n", argv[0], argc, argv[argc - 1]); + return atoi(argv[1]); + } + if (strcmp(argv[1], "raise") == 0) { + if (raise(SIGBUS) != 0) return 121; + return 122; + } + if (strcmp(argv[1], "mmap") == 0 && argc == 3) { + long page = sysconf(_SC_PAGESIZE); + int descriptor = open(argv[2], O_RDWR | O_CREAT | O_EXCL, 0600); + if (page <= 0 || descriptor < 0 || ftruncate(descriptor, page) != 0) return 123; + void *memory = mmap(NULL, (size_t)page, PROT_READ, MAP_SHARED, descriptor, 0); + if (memory == MAP_FAILED || ftruncate(descriptor, 0) != 0) return 124; + volatile const unsigned char *bytes = memory; + return bytes[0]; + } + if (strcmp(argv[1], "child-check") == 0 && argc == 3) { + if (!environment_ok(argv[2])) return 126; + for (uint32_t index = 0; index < _dyld_image_count(); ++index) { + const char *name = _dyld_get_image_name(index); + if (name != NULL && strstr(name, "trace-arm64.dylib") != NULL) return 127; + } + puts("CHILD_ENV_OK"); + return 0; + } + if (strcmp(argv[1], "child-env") == 0 && argc == 3) { + if (!environment_ok(argv[2])) return 126; + pid_t child = fork(); + if (child < 0) return 128; + if (child == 0) { + execl(argv[0], argv[0], "child-check", argv[2], (char *)NULL); + _exit(129); + } + int status = 0; + if (waitpid(child, &status, 0) != child || !WIFEXITED(status)) return 130; + return WEXITSTATUS(status); + } + if (strcmp(argv[1], "sleep") == 0) { for (;;) pause(); } + return 131; +} +''' + + +class ParserControls(unittest.TestCase): + def test_bounded_regular_record_reads(self): + with tempfile.TemporaryDirectory(prefix="ghc-sigbus-parser-") as directory: + root = Path(directory) + record = root / "record.bin" + words = [SIGBUS.MAGIC, 1, 1, SIGBUS.CPUS["arm64"]] + [0] * 60 + record.write_bytes(struct.pack("<64Q", *words)) + with mock.patch.object(Path, "read_bytes", side_effect=AssertionError("Unbounded read")): + self.assertEqual(SIGBUS.decode(record)["bytes"], 512) + record.write_bytes(bytes(4 * 512 + 1)) + with self.assertRaisesRegex(ValueError, "byte limit"): + SIGBUS.decode(record) + link = root / "record-link" + link.symlink_to(record) + with self.assertRaises(OSError): + SIGBUS.decode(link) + fifo = root / "record-fifo" + os.mkfifo(fifo) + with self.assertRaisesRegex(ValueError, "regular file"): + SIGBUS.decode(fifo) + + def test_bounded_immutable_wrapper_reads(self): + with tempfile.TemporaryDirectory(prefix="ghc-sigbus-parser-") as directory: + root = Path(directory) + binary, tracer = root / "ghc-fixture", root / "tracer.dylib" + for path, kind in ((binary, 2), (tracer, 6)): + path.write_bytes(struct.pack("<4I", 0xFEEDFACF, SIGBUS.CPUS["arm64"], 0, kind)) + source = root / "ghc-source" + source.write_text('#!/bin/sh\nexec ' + str(binary) + ' "$@"\n') + original_hash = hashlib.sha256(source.read_bytes()).hexdigest() + with mock.patch.object(Path, "read_bytes", side_effect=AssertionError("Unbounded read")): + report = SIGBUS.prepare(source, root / "copy", tracer, root / "record.bin", + "arm64", binary) + self.assertEqual(report["sourceSHA256"], original_hash) + source.write_bytes(bytes(64 * 1024 + 1)) + with self.assertRaisesRegex(ValueError, "byte limit"): + SIGBUS.prepare(source, root / "large-copy", tracer, root / "large.bin", + "arm64", binary) + link = root / "source-link" + link.symlink_to(source) + with self.assertRaises(OSError): + SIGBUS.prepare(link, root / "link-copy", tracer, root / "link.bin", "arm64", binary) + + +class Controls(unittest.TestCase): + @classmethod + def setUpClass(cls): + if platform.system() != "Darwin" or platform.machine() != "arm64": + raise unittest.SkipTest("This prototype control runs only native arm64 Darwin") + cls.directory = tempfile.TemporaryDirectory(prefix="ghc-sigbus-controls-") + cls.root = Path(cls.directory.name) + cls.compiler = subprocess.check_output(["xcrun", "--find", "clang"], text=True).strip() + cls.sdk = subprocess.check_output(["xcrun", "--show-sdk-path"], text=True).strip() + cls.commands = [] + flags = ["-isysroot", cls.sdk, "-std=c11", "-Werror=vla", "-pedantic", + "-Wall", "-Wextra", "-Werror", "-O0", "-g"] + for architecture in ("arm64", "x86_64"): + command = [cls.compiler, *flags, "-arch", architecture, "-dynamiclib", + str(HERE / "trace_ghc_sigbus.c"), "-o", + str(cls.root / f"trace-{architecture}.dylib")] + result = subprocess.run(command, capture_output=True, text=True, timeout=30) + cls.commands.append({"argv": command, "exit": result.returncode, "stderr": result.stderr}) + if result.returncode != 0: + raise AssertionError(result.stderr) + fixture = cls.root / "Fixture.c" + fixture.write_text(FIXTURE) + cls.binary = cls.root / "ghc-fixture" + subprocess.run([cls.compiler, *flags, "-arch", "arm64", str(fixture), + "-o", str(cls.binary)], check=True, capture_output=True, timeout=30) + binary_data = cls.binary.read_bytes() + command_count = struct.unpack_from("" if previous is None else previous], env)[0], 0) + self.assertEqual(len(SIGBUS.decode(record)["records"]), 1) + + def test_shape_cpu_and_record_rejections(self): + bad = self.root / "bad.sh" + bad.write_text(self.source.read_text() + "echo unexpected\n") + with self.assertRaises(ValueError): + SIGBUS.prepare(bad, self.root / "bad-copy", self.root / "trace-arm64.dylib", + self.root / "bad.bin", "arm64", self.binary) + bad.write_text('#!/bin/sh\nexec ' + str(self.binary) + '; echo injected "$@"\n') + with self.assertRaises(ValueError): + SIGBUS.prepare(bad, self.root / "separator-copy", self.root / "trace-arm64.dylib", + self.root / "separator.bin", "arm64", self.binary) + with self.assertRaises(ValueError): + SIGBUS.prepare(self.source, self.root / "wrong-cpu", self.root / "trace-x86_64.dylib", + self.root / "wrong.bin", "arm64", self.binary) + truncated = self.root / "truncated.bin" + truncated.write_bytes(b"not a record") + with self.assertRaises(ValueError): + SIGBUS.decode(truncated) + + def test_owned_timeout_cleanup(self): + wrapper, _ = self.wrapped("timeout") + with self.assertRaises(subprocess.TimeoutExpired): + self.run_owned([wrapper, "sleep"], timeout=1) + pid = self.results[-1]["pid"] + with self.assertRaises(ProcessLookupError): + os.kill(pid, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/nix/trace-ghc-sigbus.py b/scripts/nix/trace-ghc-sigbus.py new file mode 100644 index 0000000000..9f6993f262 --- /dev/null +++ b/scripts/nix/trace-ghc-sigbus.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Prepare a private compiler wrapper and decode its bounded SIGBUS record.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shlex +import stat +import struct + + +MAGIC = 0x5349474255533031 +WORDS = 64 +CPUS = {"x86_64": 0x01000007, "arm64": 0x0100000C} +PRIVATE = ( + "SIMPLEX_GHC_BUS_RECORD", + "SIMPLEX_GHC_BUS_DYLD_PRESENT", + "SIMPLEX_GHC_BUS_DYLD_VALUE", +) + + +def read_regular(path, limit): + descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(descriptor, "rb") as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + raise ValueError("Diagnostic input must be a regular file") + data = stream.read(limit + 1) + if len(data) > limit: + raise ValueError("Diagnostic input exceeds its byte limit") + return data + + +def cpu(path, file_type=None): + with Path(path).open("rb") as stream: + header = stream.read(16) + if len(header) != 16 or struct.unpack("&2 + exit 125 +fi +if [ "${DYLD_INSERT_LIBRARIES+x}" = x ]; then + export SIMPLEX_GHC_BUS_DYLD_PRESENT=1 + export SIMPLEX_GHC_BUS_DYLD_VALUE="$DYLD_INSERT_LIBRARIES" +else + export SIMPLEX_GHC_BUS_DYLD_PRESENT=0 +fi +export SIMPLEX_GHC_BUS_RECORD=RECORD +export DYLD_INSERT_LIBRARIES=TRACER${DYLD_INSERT_LIBRARIES:+:$DYLD_INSERT_LIBRARIES} +""".replace("RECORD\n", shlex.quote(str(record)) + "\n").replace( + "TRACER${", shlex.quote(str(tracer)) + "${") + prefix = b"#!/bin/sh\n" + changed = prefix + insertion.encode() + original[len(prefix):] + if changed.replace(insertion.encode(), b"", 1) != original: + raise ValueError("Private wrapper failed its reverse byte comparison") + with output.open("xb") as stream: + stream.write(changed) + output.chmod(source.stat().st_mode & 0o777) + return { + "source": str(source), "sourceSHA256": hashlib.sha256(original).hexdigest(), + "copy": str(output), "copySHA256": hashlib.sha256(changed).hexdigest(), + "expectedBinary": str(expected_binary), "cpu": architecture, + "record": str(record), "originalExecAndArgumentsPreserved": True, + } + + +def decode(path): + data = read_regular(path, 4 * WORDS * 8) + size = WORDS * 8 + if not data or len(data) > 4 * size or len(data) % size: + raise ValueError("Missing, truncated, or oversized SIGBUS record") + result = [] + kinds = set() + for offset in range(0, len(data), size): + words = struct.unpack_from("<" + "Q" * WORDS, data, offset) + if words[0] != MAGIC or words[1] != 1 or words[2] not in (1, 2): + raise ValueError("Unknown SIGBUS record format") + if words[2] in kinds or words[3] not in CPUS.values(): + raise ValueError("Duplicate record or unsupported CPU") + kinds.add(words[2]) + architecture = next(name for name, value in CPUS.items() if value == words[3]) + if words[2] == 1: + result.append({ + "kind": "startup", "cpu": architecture, "pid": words[4], + "mainImageBase": hex(words[5]), "mainImageSlide": hex(words[6]), + "mainImageUUID": data[offset + 64:offset + 80].hex() if words[7] else None, + }) + else: + names = (["rax", "rbx", "rcx", "rdx", "rdi", "rsi", "r8", "r9", + "r10", "r11", "r12", "r13", "r14", "r15"] + if architecture == "x86_64" else [f"x{index}" for index in range(29)]) + result.append({ + "kind": "fault", "cpu": architecture, "signal": words[4], + "code": struct.unpack(" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define RECORD_MAGIC UINT64_C(0x5349474255533031) +#define RECORD_WORDS 64 + +#if defined(__x86_64__) +#define RECORD_CPU CPU_TYPE_X86_64 +#elif defined(__aarch64__) || defined(__arm64__) +#define RECORD_CPU CPU_TYPE_ARM64 +#else +#error "The SIGBUS diagnostic requires 64-bit Darwin" +#endif + +/* This is the only static value read from the signal handler. */ +_Static_assert(ATOMIC_INT_LOCK_FREE == 2, "The handler requires lock-free int atomics"); +static atomic_int record_fd = ATOMIC_VAR_INIT(-1); + +static void fail(void) +{ + static const char message[] = "GHC SIGBUS diagnostic: initialization failed\n"; + (void)write(STDERR_FILENO, message, sizeof(message) - 1); + _exit(125); +} + +static void fault_record(int signal_number, siginfo_t *information, void *context) +{ + /* No formatter, allocator, unwinder, dyld call, or fault-address read here. + * A short/interrupted write is reported as a truncated record by the reader. + */ + uint64_t record[RECORD_WORDS] = {0}; + record[0] = RECORD_MAGIC; + record[1] = 1; + record[2] = 2; + record[3] = RECORD_CPU; + record[4] = (uint64_t)signal_number; + if (information != NULL) { + record[5] = (uint64_t)(int64_t)information->si_code; + record[6] = (uint64_t)(uintptr_t)information->si_addr; + } + ucontext_t *machine = context; + if (machine != NULL && machine->uc_mcontext != NULL) { +#if defined(__x86_64__) + const struct __darwin_x86_thread_state64 *state = &machine->uc_mcontext->__ss; + record[7] = state->__rip; + record[8] = state->__rsp; + record[9] = state->__rbp; + record[11] = state->__rflags; + record[12] = state->__rax; + record[13] = state->__rbx; + record[14] = state->__rcx; + record[15] = state->__rdx; + record[16] = state->__rdi; + record[17] = state->__rsi; + record[18] = state->__r8; + record[19] = state->__r9; + record[20] = state->__r10; + record[21] = state->__r11; + record[22] = state->__r12; + record[23] = state->__r13; + record[24] = state->__r14; + record[25] = state->__r15; +#else + const struct __darwin_arm_thread_state64 *state = &machine->uc_mcontext->__ss; + record[7] = state->__pc; + record[8] = state->__sp; + record[9] = state->__fp; + record[10] = state->__lr; + record[11] = state->__cpsr; + for (unsigned int index = 0; index < 29; ++index) { + record[12 + index] = state->__x[index]; + } +#endif + } + (void)write(atomic_load_explicit(&record_fd, memory_order_relaxed), record, sizeof(record)); + /* SA_RESETHAND restored SIG_DFL. Queue the same signal while it is blocked, + * then return for default termination. Darwin raise() can report the same + * si_code as a hardware fault, so returning alone could swallow that signal. + * The record above retains the original fault address and machine context. + */ + (void)kill(getpid(), signal_number); +} + +__attribute__((constructor)) static void initialize(void) +{ + const char *path = getenv("SIMPLEX_GHC_BUS_RECORD"); + const char *present = getenv("SIMPLEX_GHC_BUS_DYLD_PRESENT"); + const char *previous = getenv("SIMPLEX_GHC_BUS_DYLD_VALUE"); + if (path == NULL || path[0] != '/' || present == NULL || + (strcmp(present, "0") != 0 && strcmp(present, "1") != 0) || + (strcmp(present, "1") == 0 && previous == NULL)) { + fail(); + } + const int had_previous = strcmp(present, "1") == 0; + char *previous_copy = had_previous ? strdup(previous) : NULL; + if (had_previous && previous_copy == NULL) { + fail(); + } + int descriptor = open(path, O_WRONLY | O_CREAT | O_EXCL | O_APPEND | O_CLOEXEC, 0600); + if (descriptor < 0) { + fail(); + } + atomic_store_explicit(&record_fd, descriptor, memory_order_relaxed); + int restored = had_previous ? setenv("DYLD_INSERT_LIBRARIES", previous_copy, 1) + : unsetenv("DYLD_INSERT_LIBRARIES"); + free(previous_copy); + if (restored != 0 || unsetenv("SIMPLEX_GHC_BUS_RECORD") != 0 || + unsetenv("SIMPLEX_GHC_BUS_DYLD_PRESENT") != 0 || + unsetenv("SIMPLEX_GHC_BUS_DYLD_VALUE") != 0) { + fail(); + } + struct rlimit core_limit; + if (getrlimit(RLIMIT_CORE, &core_limit) != 0) { + fail(); + } + core_limit.rlim_cur = 0; + if (setrlimit(RLIMIT_CORE, &core_limit) != 0) { + fail(); + } + struct sigaction previous_action; + if (sigaction(SIGBUS, NULL, &previous_action) != 0 || previous_action.sa_handler != SIG_DFL) { + fail(); + } + + /* Startup metadata is captured outside the signal handler. */ + uint64_t record[RECORD_WORDS] = {0}; + record[0] = RECORD_MAGIC; + record[1] = 1; + record[2] = 1; + record[3] = RECORD_CPU; + record[4] = (uint64_t)getpid(); + const struct mach_header_64 *header = NULL; + intptr_t slide = 0; + uint32_t image_count = _dyld_image_count(); + if (image_count > 1024) { + fail(); + } + for (uint32_t index = 0; index < image_count; ++index) { + const struct mach_header *image = _dyld_get_image_header(index); + if (image != NULL && image->filetype == MH_EXECUTE) { + if (header != NULL) { + fail(); + } + header = (const struct mach_header_64 *)image; + slide = _dyld_get_image_vmaddr_slide(index); + } + } + if (header == NULL || header->magic != MH_MAGIC_64 || + header->cputype != RECORD_CPU || header->sizeofcmds > 1024 * 1024 || + header->ncmds > 1024) { + fail(); + } + record[5] = (uint64_t)(uintptr_t)header; + record[6] = (uint64_t)slide; + const unsigned char *commands = (const unsigned char *)(header + 1); + size_t offset = 0; + for (uint32_t index = 0; index < header->ncmds; ++index) { + if (offset > header->sizeofcmds || header->sizeofcmds - offset < sizeof(struct load_command)) { + fail(); + } + const struct load_command *command = (const struct load_command *)(commands + offset); + if (command->cmdsize < sizeof(*command) || command->cmdsize > header->sizeofcmds - offset) { + fail(); + } + if (command->cmd == LC_UUID) { + if (command->cmdsize != sizeof(struct uuid_command) || record[7] != 0) { + fail(); + } + const struct uuid_command *uuid = (const struct uuid_command *)command; + memcpy(&record[8], uuid->uuid, sizeof(uuid->uuid)); + record[7] = 1; + } + offset += command->cmdsize; + } + if (write(descriptor, record, sizeof(record)) != (ssize_t)sizeof(record)) { + fail(); + } + struct sigaction action = {0}; + action.sa_sigaction = fault_record; + action.sa_flags = SA_SIGINFO | SA_RESETHAND; + if (sigemptyset(&action.sa_mask) != 0 || sigaction(SIGBUS, &action, NULL) != 0) { + fail(); + } +}