| 1 | """Syscall-boundary crash points (ALICE/CrashMonkey method, process-crash only).
|
| 2 |
|
| 3 | A tiny interposer library counts durability boundaries (fsync, F_FULLFSYNC /
|
| 4 | F_BARRIERFSYNC, rename) in the subject process and SIGKILLs it immediately
|
| 5 | before the N-th one. Enumerating N = 1..count gives a crash state at every
|
| 6 | durability boundary the operation crosses, instead of guessing time offsets.
|
| 7 |
|
| 8 | Scope and honesty:
|
| 9 | - This is process death, not power loss: the page cache survives, so
|
| 10 | un-fsynced writes are NOT dropped. Power-loss states need LazyFS (Linux,
|
| 11 | external tool); rows say ``crash_model: process_kill``.
|
| 12 | - macOS: DYLD_INSERT_LIBRARIES is ignored for hardened-runtime binaries, so
|
| 13 | the harness runs an ad-hoc re-signed copy of the SAME Mach-O (identical code;
|
| 14 | both sha256 recorded). Git needs the copy named ``git`` (builtin dispatch)
|
| 15 | and GIT_EXEC_PATH pointing at the original libexec. Children exec'd from the
|
| 16 | original signed libexec do not load the library (they are not crash targets).
|
| 17 | - Linux: LD_PRELOAD with the same source (symbol wrapping via dlsym) is the
|
| 18 | intended port; unsupported rows are emitted until it is exercised.
|
| 19 | """
|
| 20 |
|
| 21 | from __future__ import annotations
|
| 22 |
|
| 23 | import hashlib
|
| 24 | import os
|
| 25 | import platform
|
| 26 | import shutil
|
| 27 | import subprocess
|
| 28 | from dataclasses import dataclass
|
| 29 | from pathlib import Path
|
| 30 | from typing import Optional
|
| 31 |
|
| 32 | INTERPOSER_SOURCE = r"""
|
| 33 | #include <stdio.h>
|
| 34 | #include <stdlib.h>
|
| 35 | #include <unistd.h>
|
| 36 | #include <signal.h>
|
| 37 | #include <fcntl.h>
|
| 38 | #include <stdarg.h>
|
| 39 | static long counter = 0, target = -1; static int inited = 0; static const char *logp = 0;
|
| 40 | static void init(void) { if (inited) return; inited = 1;
|
| 41 | const char *t = getenv("P4_CRASH_AT"); if (t) target = atol(t); logp = getenv("P4_CRASH_LOG"); }
|
| 42 | static void tick(const char *what) { init(); counter++;
|
| 43 | if (logp) { int fd = open(logp, O_WRONLY | O_APPEND | O_CREAT, 0644);
|
| 44 | if (fd >= 0) { char b[96]; int n = snprintf(b, sizeof b, "%d %ld %s\n", (int)getpid(), counter, what); write(fd, b, n); close(fd); } }
|
| 45 | if (target > 0 && counter == target) kill(getpid(), SIGKILL); }
|
| 46 | static int p4_fsync(int fd) { tick("fsync"); return fsync(fd); }
|
| 47 | static int p4_rename(const char *a, const char *b) { tick("rename"); return rename(a, b); }
|
| 48 | static int p4_fcntl(int fd, int cmd, ...) { va_list ap; va_start(ap, cmd); void *arg = va_arg(ap, void *); va_end(ap);
|
| 49 | if (cmd == F_FULLFSYNC || cmd == F_BARRIERFSYNC) tick("fullfsync"); return fcntl(fd, cmd, arg); }
|
| 50 | #define INTERPOSE(r, o) __attribute__((used)) static struct { const void *replacement; const void *replacee; } \
|
| 51 | _interpose_##o __attribute__((section("__DATA,__interpose"))) = { (const void *)(unsigned long)&r, (const void *)(unsigned long)&o };
|
| 52 | INTERPOSE(p4_fsync, fsync)
|
| 53 | INTERPOSE(p4_rename, rename)
|
| 54 | INTERPOSE(p4_fcntl, fcntl)
|
| 55 | """
|
| 56 |
|
| 57 |
|
| 58 | @dataclass
|
| 59 | class CrashInstrument:
|
| 60 | dylib: Path
|
| 61 | oak_bin: Optional[Path]
|
| 62 | git_bin: Optional[Path]
|
| 63 | git_exec_path: Optional[str]
|
| 64 | provenance: dict
|
| 65 |
|
| 66 | def env(self, target: int, log: Optional[Path] = None, kind: str = "oak") -> dict:
|
| 67 | env = {"DYLD_INSERT_LIBRARIES": str(self.dylib), "P4_CRASH_AT": str(target)}
|
| 68 | if log is not None:
|
| 69 | env["P4_CRASH_LOG"] = str(log)
|
| 70 | if kind == "git" and self.git_exec_path:
|
| 71 | env["GIT_EXEC_PATH"] = self.git_exec_path
|
| 72 | return env
|
| 73 |
|
| 74 |
|
| 75 | def _sha(path: Path) -> str:
|
| 76 | return hashlib.sha256(path.read_bytes()).hexdigest()
|
| 77 |
|
| 78 |
|
| 79 | def build(workdir: Path, oak_bin: Optional[str], git_bin: Optional[str]) -> tuple[Optional[CrashInstrument], str]:
|
| 80 | """Build the interposer + re-signed subject copies. Returns (instrument, reason)."""
|
| 81 | if platform.system() != "Darwin":
|
| 82 | return None, "syscall crash points implemented for macOS DYLD interposition only (Linux LD_PRELOAD port pending)"
|
| 83 | clang = shutil.which("clang")
|
| 84 | codesign = shutil.which("codesign")
|
| 85 | if not clang or not codesign:
|
| 86 | return None, "clang and codesign required to build the crash interposer"
|
| 87 | workdir.mkdir(parents=True, exist_ok=True)
|
| 88 | source = workdir / "p4crash.c"
|
| 89 | source.write_text(INTERPOSER_SOURCE)
|
| 90 | dylib = workdir / "p4crash.dylib"
|
| 91 | proc = subprocess.run([clang, "-dynamiclib", "-O1", "-o", str(dylib), str(source)], capture_output=True, text=True)
|
| 92 | if proc.returncode != 0:
|
| 93 | return None, "interposer build failed: " + proc.stderr[-300:]
|
| 94 | provenance = {"interposer_sha256": _sha(dylib), "crash_model": "process_kill"}
|
| 95 | oak_copy = git_copy = None
|
| 96 | git_exec = None
|
| 97 | if oak_bin:
|
| 98 | oak_copy = workdir / "oak-bin" / "oak"
|
| 99 | oak_copy.parent.mkdir(exist_ok=True)
|
| 100 | shutil.copy2(oak_bin, oak_copy)
|
| 101 | subprocess.run([codesign, "-f", "-s", "-", str(oak_copy)], capture_output=True)
|
| 102 | provenance.update(oak_original_sha256=_sha(Path(oak_bin)), oak_adhoc_sha256=_sha(oak_copy))
|
| 103 | if git_bin:
|
| 104 | git_copy = workdir / "git-bin" / "git"
|
| 105 | git_copy.parent.mkdir(exist_ok=True)
|
| 106 | shutil.copy2(git_bin, git_copy)
|
| 107 | subprocess.run([codesign, "-f", "-s", "-", str(git_copy)], capture_output=True)
|
| 108 | exec_path = subprocess.run([git_bin, "--exec-path"], capture_output=True, text=True).stdout.strip()
|
| 109 | git_exec = exec_path or None
|
| 110 | provenance.update(git_original_sha256=_sha(Path(git_bin)), git_adhoc_sha256=_sha(git_copy),
|
| 111 | git_exec_path=git_exec)
|
| 112 | return CrashInstrument(dylib, oak_copy, git_copy, git_exec, provenance), ""
|
| 113 |
|
| 114 |
|
| 115 | def count_boundaries(log: Path, pid: Optional[int] = None) -> int:
|
| 116 | """Durability boundaries crossed by the first (top-level) process in the log."""
|
| 117 | if not log.exists():
|
| 118 | return 0
|
| 119 | counts: dict[str, int] = {}
|
| 120 | first: Optional[str] = None
|
| 121 | for line in log.read_text().splitlines():
|
| 122 | parts = line.split()
|
| 123 | if len(parts) < 3:
|
| 124 | continue
|
| 125 | first = first or parts[0]
|
| 126 | counts[parts[0]] = max(counts.get(parts[0], 0), int(parts[1]))
|
| 127 | if pid is not None:
|
| 128 | return counts.get(str(pid), 0)
|
| 129 | return counts.get(first, 0) if first else 0
|
| 130 |
|
| 131 |
|
| 132 | def boundary_kinds(log: Path) -> list[str]:
|
| 133 | if not log.exists():
|
| 134 | return []
|
| 135 | lines = [line.split() for line in log.read_text().splitlines() if line.strip()]
|
| 136 | if not lines:
|
| 137 | return []
|
| 138 | first = lines[0][0]
|
| 139 | return [parts[2] for parts in lines if parts[0] == first]
|