CultLib/scripts/mutate-cultmesh.mjs
Meta Vens 180917d75b Mutate the native bridge, and name the target each entry is honest on
The mutation suite covered only the TypeScript rules. The bridge's two runtime
lifetime rules now have entries of their own, each with a revert that puts back
the wrong spelling and a loosening that keeps the shape and gives away the
guarantee: a notify moved outside the gate, a notify outside it that fires only
on the last call out, the quiesce wait deleted, and the quiesce wait made
bounded. Their killer is the scenario runner, built through CMake rather than
npm, so the runner now takes a check per target instead of assuming a workspace,
and a target without a dist/ keeps the verified restore without the sentinel.

Every native entry names the platforms it is honest on, and a run elsewhere
reports it as skipped rather than killed. There is no ThreadSanitizer for MSVC,
so the notify rule cannot be seen on win32-x64 at all. Soul's Cut 3 pass found
this the hard way, reporting two mutations killed from a win32-x64 release run
where both in fact survive.

Naming a target on the command line runs only that one, which is what lets the
native entries run inside the Debian 13 container where the TypeScript workspaces
have no installed dependencies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:23:50 +02:00

700 lines
37 KiB
JavaScript

// Mutation check for the CultMesh rules that are not proven by reading: the
// route verifier, the realtime frame codec, and the native bridge's runtime
// lifetime.
//
// Two files own the route rules and each is one target here. `shared` is
// `packages/cultnet-ts/src/cultmesh-authority.ts`, the one verifier, killed by
// `npm run test --workspace packages/cultnet-ts`. `browser` is
// `packages/cultmesh-browser/src/index.ts`, which must *call* the shared rules
// rather than re-read them locally, killed by `npm run test --workspace
// packages/cultmesh-browser`. A browser mutation cannot be killed by the
// cultnet-ts tests and a shared one is not the browser's to pin, so the killer
// is the target's own workspace.
//
// Each rule the cut map names gets one mutation. A survivor means no test pins
// that rule. Each file is rewritten as UTF-8 bytes and restored from the
// original bytes with a digest check, never from git. A no-op control runs
// first per target through the same write path so a false kill from the write
// itself cannot hide.
//
// The original bytes also live in `<file>.mutation-original` from the first
// write until a verified restore; a run killed mid-mutation is repaired from
// that sidecar by the next run, not by git. After the final restore each
// package is rebuilt so `dist/` never carries the last mutant.
//
// `realtime` is `packages/cultmesh-ts/src/realtime-wire.ts`, the frame codec,
// killed by `npm run test --workspace packages/cultmesh-ts`.
//
// `native` is `native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp`, and
// its killer is the scenario runner in that project's `tests/`, built and run
// through CMake rather than npm. It has no `dist/`, so it has no sentinel and no
// post-run rebuild check; the verified restore covers it.
//
// A mutation is only killed on a target that can see it. Some of the native
// rules are visible only under a sanitizer, and there is no ThreadSanitizer for
// MSVC; a run on the wrong platform would otherwise report "killed" for a
// mutation it never actually exercised. Every native entry therefore names the
// platforms it is honest on, and a run elsewhere reports it as `skipped`, which
// is not coverage. Soul's Cut 3 pass found this the hard way: `B1` and `L2` were
// reported killed from a win32-x64 release run where both in fact survive, and
// die deterministically only under linux-x64 AddressSanitizer.
//
// This script tests the platform it is running on. For the linux-x64 native
// entries, run it inside the Debian 13 container the bridge is built in, with
// cmake, ninja, a C++20 compiler and Node present; a Windows run reports those
// entries as skipped and says so.
//
// node scripts/mutate-cultmesh.mjs [target...]
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const npmCli = process.env.npm_execpath ??
join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
// The platform this run can speak for. It is the target the native entries are
// matched against, and it is named in the output so a report cannot be read as
// covering a platform it never touched.
const platform = process.platform === "win32" ? "win32-x64" : "linux-x64";
// Each target's `sentinel` is present in its restored source and compiled
// verbatim into its dist/; that target's last mutation removes it, which is
// what makes the post-rebuild check distinguish a rebuilt dist/ from one still
// carrying that mutant.
const targets = {
shared: {
workspace: "packages/cultnet-ts",
file: join(repoRoot, "packages", "cultnet-ts", "src", "cultmesh-authority.ts"),
dist: join(repoRoot, "packages", "cultnet-ts", "dist", "cultmesh-authority.js"),
sentinel: 'scheme === "https"',
},
browser: {
workspace: "packages/cultmesh-browser",
file: join(repoRoot, "packages", "cultmesh-browser", "src", "index.ts"),
dist: join(repoRoot, "packages", "cultmesh-browser", "dist", "index.js"),
sentinel: "isUnsignedCertificate(route.certificate)",
},
realtime: {
workspace: "packages/cultmesh-ts",
file: join(repoRoot, "packages", "cultmesh-ts", "src", "realtime-wire.ts"),
dist: join(repoRoot, "packages", "cultmesh-ts", "dist", "realtime-wire.js"),
sentinel: "0x31545343",
},
native: {
file: join(repoRoot, "native", "GameCult.Mesh.Quic.Native", "cultmesh_quic_native.cpp"),
check: () => nativeScenariosPass(),
},
};
const mutations = [
{
rule: "unsigned remote route is refused",
old: 'throw new Error("Remote CultMesh routes require an Odin-signed authority certificate.");',
new: "return;",
},
{
rule: "unsigned loopback is accepted only under local-development",
old: 'if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint)) return;',
new: "if (isLoopbackEndpoint(route.endpoint)) return;",
},
{
rule: "protected-scheme gate",
old: "if (!isProtectedEndpoint(route.endpoint) && !(",
new: "if (isProtectedEndpoint(route.endpoint) && !(",
},
{
rule: "validity window (expiresAt exclusive)",
old: "now >= certificate.expiresAtUnixMilliseconds",
new: "now > certificate.expiresAtUnixMilliseconds",
},
{
rule: "Odin root lookup by key id",
old: "const root = roots.get(odinKeyId);",
new: "const root = [...roots.values()][0];",
},
{
rule: "isUnsignedCertificate treats a whitespace signature as unsigned",
old: "return !certificate || isNullOrWhiteSpaceCSharp(certificate.signature);",
new: 'return !certificate || certificate.signature === "";',
},
{
rule: "isUnsignedCertificate treats an empty signature as unsigned",
old: "return !certificate || isNullOrWhiteSpaceCSharp(certificate.signature);",
new: "return !certificate;",
},
{
rule: "the whitespace set is C#'s char.IsWhiteSpace, not String.prototype.trim",
old: "return !certificate || isNullOrWhiteSpaceCSharp(certificate.signature);",
new: 'return !certificate || certificate.signature.trim() === "";',
},
{
rule: "U+0085 is whitespace to C# (String.prototype.trim disagrees)",
old: "code === 0x85 || ",
new: "",
},
{
rule: "the two non-verifying signature refusals keep their own C# messages",
old: ' throw new Error("The Odin route signature is not base64.");\n' +
" }\n" +
" if (signatureBytes.byteLength !== 64) {\n" +
' throw new Error("The Odin route signature is not IEEE P1363 P-256.");\n',
new: ' throw new Error("The Odin route certificate signature is invalid.");\n' +
" }\n" +
" if (signatureBytes.byteLength !== 64) {\n" +
' throw new Error("The Odin route certificate signature is invalid.");\n',
},
{
rule: "root lookup precedes the validity window",
old: " const odinKeyId = certificateOdinKeyId(certificate);\n" +
" const root = roots.get(odinKeyId);\n" +
" if (!root) throw new Error(`Odin key '${odinKeyId}' is not trusted by this consumer.`);\n" +
" const now = trust.now?.() ?? Date.now();\n" +
" if (now < certificate.issuedAtUnixMilliseconds || now >= certificate.expiresAtUnixMilliseconds) {\n" +
' throw new Error("The Odin route certificate is not currently valid.");\n' +
" }\n",
new: " const now = trust.now?.() ?? Date.now();\n" +
" if (now < certificate.issuedAtUnixMilliseconds || now >= certificate.expiresAtUnixMilliseconds) {\n" +
' throw new Error("The Odin route certificate is not currently valid.");\n' +
" }\n" +
" const odinKeyId = certificateOdinKeyId(certificate);\n" +
" const root = roots.get(odinKeyId);\n" +
" if (!root) throw new Error(`Odin key '${odinKeyId}' is not trusted by this consumer.`);\n",
},
{
rule: "duplicate Odin root key ids refuse the policy",
old: " if (roots.has(keyId)) throw new Error(`Odin root key id '${keyId}' is listed more than once in the trust policy.`);\n",
new: "",
},
{
rule: "protocol ids are sorted into the transcript",
old: ")].sort();",
new: ")];",
},
{
rule: "verifyP256 copies the viewed payload bytes, not the pool behind a Buffer",
old: " ownedBytes(payload),",
new: " payload.slice().buffer as ArrayBuffer,",
},
{
rule: "route transcript field order",
old: " route.verseId,\n authorityRuntimeId,\n",
new: " authorityRuntimeId,\n route.verseId,\n",
},
{
rule: "session transcript binds the nonce",
old: ' request.clientNonce ?? "",\n',
new: "",
},
{
rule: "protocol ids are deduplicated into the transcript",
old: " return [...new Set(",
new: " return [...(",
},
{
rule: "the generation is trimmed into the transcript",
old: ": trimCSharp(route.generation),",
new: ": route.generation,",
},
{
rule: "the provider key id is trimmed into the transcript",
old: ' requireNonEmptyCSharp(certificate.providerKey.keyId, "keyId"),',
new: " certificate.providerKey.keyId,",
},
{
rule: "the Odin key id is trimmed before the root lookup",
old: " const odinKeyId = certificateOdinKeyId(certificate);",
new: " const odinKeyId = certificate.odinKeyId;",
},
{
rule: "blank protocol ids are dropped from the transcript",
old: "(values ?? []).filter(value => !isNullOrWhiteSpaceCSharp(value)).map(trimCSharp)",
new: "(values ?? []).map(trimCSharp)",
},
{
rule: "length-prefix framing is big-endian",
old: "view.setUint32(offset, value.byteLength, false);",
new: "view.setUint32(offset, value.byteLength, true);",
},
{
rule: "P-256 verification hashes with SHA-256",
old: '{ name: "ECDSA", hash: "SHA-256" },',
new: '{ name: "ECDSA", hash: "SHA-384" },',
},
// Not listed: removing `signature.byteLength !== 64` from verifyP256. WebCrypto
// refuses a non-64-byte P1363 signature on its own, so that guard has no
// observable behaviour and the mutant is equivalent (it survived when tried).
{
rule: "loopback recognises ::1",
old: 'host === "::1"',
new: 'host === "::2"',
},
{
rule: "loopback is all of 127.0.0.0/8",
old: "/^127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$/.test(host)",
new: 'host === "127.0.0.1"',
},
{
rule: "loopback recognises the host name loopback",
old: 'host === "loopback" || ',
new: "",
},
{
rule: "loopback recognises the IPv4-mapped ::ffff:127.0.0.1",
old: 'host === "::ffff:7f00:1" ||',
new: "",
},
{
rule: "a trailing dot on the host is refused, as System.Uri refuses it",
old: ' || host.endsWith(".");',
new: ";",
},
{
rule: "a scheme not followed by // and an authority is refused",
old: " if (!shape) return true;",
new: " if (!shape) return false;",
},
{
rule: "a backslash in the endpoint is refused",
old: ' if (raw.includes("\\\\")) return true;\n',
new: "",
},
{
rule: "a non-ASCII host is refused, so URL cannot fold a fullwidth form to ASCII",
old: " return /[^\\u0021-\\u007e]/.test(host)",
new: " return false",
},
{
rule: "protected scheme: any scheme containing quic",
old: 'scheme.includes("quic")',
new: 'scheme === "quic"',
},
{
// Only `isProtectedEndpoint`'s copy: `isLoopbackEndpoint` keeps its own, so
// this pins that the protection gate refuses the spellings `URL` repairs
// rather than inheriting the answer from the loopback rule.
rule: "isProtectedEndpoint refuses the raw shapes System.Uri refuses",
old: " if (refusedByCSharpUriShape(value)) return false;\n let scheme: string;\n",
new: " let scheme: string;\n",
},
{
rule: "protected scheme: https",
old: 'scheme === "https" || ',
new: "",
},
// The browser target. Its session-acceptance short-circuit must ask the
// shared module what "unsigned" means; a local re-reading here and the route
// verifier's reading there can drift apart, and a browser route that the
// verifier let through unsigned would then be asked for a provider proof.
{
target: "browser",
rule: "the session short-circuit calls the shared isUnsignedCertificate, not a local reading",
old: "isUnsignedCertificate(route.certificate)",
new: '(route.certificate?.signature ?? "") === ""',
},
// The realtime frame codec. Every rule the cut map names for it gets one
// mutation; the killer is `npm run test --workspace packages/cultmesh-ts`,
// whose vectors are written by the C# reference and by this codec in turn.
// A mutation that changes the encoding symmetrically (an endianness flip, a
// field swap) still dies, because the vector bytes came from the other side.
{ target: "realtime", rule: "the header is little-endian", old: "view.setUint32(0, MAGIC, true);", new: "view.setUint32(0, MAGIC, false);" },
{ target: "realtime", rule: "the delivery byte sits at offset 4", old: " result[4] = deliveryByte;\n", new: " result[5] = deliveryByte;\n" },
{ target: "realtime", rule: "the producer epoch is written before the sequence", old: " view.setBigInt64(5, frame.producerEpoch, true);\n view.setBigInt64(13, frame.sequence, true);\n", new: " view.setBigInt64(5, frame.sequence, true);\n view.setBigInt64(13, frame.producerEpoch, true);\n" },
{ target: "realtime", rule: "the epoch and sequence are 64-bit, not 32-bit", old: " view.setBigInt64(5, frame.producerEpoch, true);", new: " view.setInt32(5, Number(frame.producerEpoch), true);" },
{ target: "realtime", rule: "the three identity lengths are written channel, schema, body", old: " view.setUint16(23, schema.byteLength, true);\n view.setUint16(25, body.byteLength, true);\n", new: " view.setUint16(23, body.byteLength, true);\n view.setUint16(25, schema.byteLength, true);\n" },
{ target: "realtime", rule: "the identity length prefixes are 16-bit, not 8-bit", old: " view.setUint16(21, channel.byteLength, true);", new: " view.setUint8(21, channel.byteLength);" },
{ target: "realtime", rule: "the identity length prefixes are little-endian", old: " view.setUint16(21, channel.byteLength, true);", new: " view.setUint16(21, channel.byteLength, false);" },
{ target: "realtime", rule: "the payload length prefix sits at offset 27", old: " view.setInt32(27, frame.payload.byteLength, true);", new: " view.setInt32(28, frame.payload.byteLength, true);" },
{ target: "realtime", rule: "the header size is stamped into the frame", old: " view.setInt32(31, FIXED_HEADER_BYTES, true);", new: " view.setInt32(31, FIXED_HEADER_BYTES + 1, true);" },
{ target: "realtime", rule: "the wire version is stamped into the frame", old: " view.setUint16(35, WIRE_VERSION, true);", new: " view.setUint16(35, WIRE_VERSION + 1, true);" },
{ target: "realtime", rule: "the identities are written channel, schema, body, then the payload", old: " result.set(channel, offset); offset += channel.byteLength;\n result.set(schema, offset); offset += schema.byteLength;\n", new: " result.set(schema, offset); offset += schema.byteLength;\n result.set(channel, offset); offset += channel.byteLength;\n" },
{ target: "realtime", rule: "the fixed header is 37 bytes", old: "const FIXED_HEADER_BYTES = 37;", new: "const FIXED_HEADER_BYTES = 38;" },
{ target: "realtime", rule: "delivery encodes as the C# enum ordinal, reliable-ordered first", old: 'const DELIVERY_BYTES: readonly CultMeshRealtimeDelivery[] = ["reliable-ordered", "latest-only", "unreliable"];', new: 'const DELIVERY_BYTES: readonly CultMeshRealtimeDelivery[] = ["latest-only", "reliable-ordered", "unreliable"];' },
{ target: "realtime", rule: "an identity the delivery union cannot spell is refused", old: ' if (deliveryByte < 0) throw new Error("Realtime frame delivery mode is invalid.");\n', new: "" },
{ target: "realtime", rule: "identities are UTF-8, and the prefix counts bytes not characters", old: " const channel = encoder.encode(frame.channelId);", new: " const channel = Uint8Array.from(frame.channelId, c => c.charCodeAt(0) & 0xff);" },
{ target: "realtime", rule: "an identity over 65535 bytes is refused", old: " if (channel.byteLength > MAX_IDENTITY_BYTES || schema.byteLength > MAX_IDENTITY_BYTES || body.byteLength > MAX_IDENTITY_BYTES) {\n throw new Error(\"Realtime frame identity exceeds the QUIC wire limit.\");\n }\n", new: "" },
{ target: "realtime", rule: "a payload over the 64 MiB ceiling is refused", old: " if (frame.payload.byteLength > CULTMESH_REALTIME_MAX_PAYLOAD_BYTES) {\n throw new Error(\"Realtime frame payload exceeds the QUIC wire limit.\");\n }\n", new: "" },
{ target: "realtime", rule: "the payload ceiling is 64 MiB", old: "export const CULTMESH_REALTIME_MAX_PAYLOAD_BYTES = 64 * 1024 * 1024;", new: "export const CULTMESH_REALTIME_MAX_PAYLOAD_BYTES = 32 * 1024 * 1024;" },
{ target: "realtime", rule: "the encoded ceiling covers the header and three maximal identities", old: " CULTMESH_REALTIME_MAX_PAYLOAD_BYTES + 37 + 3 * 0xffff;", new: " CULTMESH_REALTIME_MAX_PAYLOAD_BYTES + 37 + 0xffff;" },
{ target: "realtime", rule: "the ALPN is cultmesh-state-v1", old: 'export const CULTMESH_REALTIME_ALPN = "cultmesh-state-v1";', new: 'export const CULTMESH_REALTIME_ALPN = "cultmesh-state-v2";' },
{ target: "realtime", rule: "the QUIC connection close and stream abort codes", old: "export const CULTMESH_REALTIME_CONNECTION_CLOSE_CODE = 0x43554c54n;", new: "export const CULTMESH_REALTIME_CONNECTION_CLOSE_CODE = 0x53544154n;" },
{ target: "realtime", rule: "the two stream kinds are 1 and 2", old: "export const CULTMESH_REALTIME_RELIABLE_STREAM = 1;", new: "export const CULTMESH_REALTIME_RELIABLE_STREAM = 2;" },
{ target: "realtime", rule: "a blank channel identity is refused, on C#'s whitespace set", old: ' if (isNullOrWhiteSpaceCSharp(frame.channelId)) throw new Error("Realtime channel identity is required.");\n', new: "" },
{ target: "realtime", rule: "the blank reading is C#'s IsNullOrWhiteSpace, not String.prototype.trim", old: " if (isNullOrWhiteSpaceCSharp(frame.schemaId)) throw new Error", new: ' if (frame.schemaId.trim() === "") throw new Error' },
{ target: "realtime", rule: "a blank body identity is refused", old: ' if (isNullOrWhiteSpaceCSharp(frame.bodyId)) throw new Error("Realtime body identity is required.");\n', new: "" },
{ target: "realtime", rule: "a negative epoch or sequence is refused", old: " if (frame.producerEpoch < 0n || frame.sequence < 0n) {", new: " if (false) {" },
{ target: "realtime", rule: "an epoch or sequence outside the i64 range is refused", old: " if (frame.producerEpoch > INT64_MAX || frame.sequence > INT64_MAX || frame.producerEpoch < INT64_MIN || frame.sequence < INT64_MIN) {", new: " if (false) {" },
{ target: "realtime", rule: "decode refuses a truncated header", old: ' if (bytes.byteLength < FIXED_HEADER_BYTES) throw new Error("Realtime frame header is truncated.");\n', new: "" },
{ target: "realtime", rule: "decode checks the magic", old: ' if (view.getUint32(0, true) !== MAGIC) throw new Error("Realtime frame magic is invalid.");\n', new: "" },
{ target: "realtime", rule: "decode checks the wire version", old: " if (view.getUint16(35, true) !== WIRE_VERSION || view.getInt32(31, true) !== FIXED_HEADER_BYTES) {", new: " if (view.getInt32(31, true) !== FIXED_HEADER_BYTES) {" },
{ target: "realtime", rule: "decode checks the stamped header size", old: " if (view.getUint16(35, true) !== WIRE_VERSION || view.getInt32(31, true) !== FIXED_HEADER_BYTES) {", new: " if (view.getUint16(35, true) !== WIRE_VERSION) {" },
{ target: "realtime", rule: "decode refuses a delivery byte outside the three modes", old: ' if (delivery === undefined) throw new Error("Realtime frame delivery mode is invalid.");\n', new: ' if (delivery === undefined) return { channelId: "", schemaId: "", bodyId: "", producerEpoch: 0n, sequence: 0n, delivery: "unreliable", payload: new Uint8Array(0) };\n' },
{ target: "realtime", rule: "decode refuses a length sum that misses the frame", old: " if (payloadLength < 0 || expected !== bytes.byteLength) throw", new: " if (payloadLength < 0) throw" },
// This one was first listed as equivalent — "a negative payload length makes
// the sum smaller than the frame, so the sum check covers it" — and it did
// survive. The reasoning was wrong: a negative payload length cancels against
// the identity lengths, so a 37-byte frame with channelLength 65535 and
// payloadLength -65535 sums to exactly 37 and decoded to blank identities
// while the reference refused it. No test covered that shape; one does now.
{ target: "realtime", rule: "decode refuses a negative payload length the sum cancels out", old: " if (payloadLength < 0 || expected !== bytes.byteLength) throw", new: " if (expected !== bytes.byteLength) throw" },
{ target: "realtime", rule: "decode keeps a leading byte-order mark, as Encoding.UTF8.GetString does", old: 'const decoder = new TextDecoder("utf-8", { ignoreBOM: true });', new: "const decoder = new TextDecoder();" },
{ target: "realtime", rule: "decode reads exactly the viewed bytes of an offset view", old: " const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);", new: " const view = new DataView(bytes.buffer);" },
{ target: "realtime", rule: "the decoded payload does not alias the frame it came from", old: " payload: new Uint8Array(bytes.subarray(offset, offset + payloadLength)),", new: " payload: bytes.subarray(offset, offset + payloadLength)," },
// The narrower half of the same rule, and the one that shipped broken: a plain
// `bytes.slice` copies a `Uint8Array` but aliases a `Buffer`, whose own `slice`
// is an alias of `subarray`. The mutation above dies on the `Uint8Array` test;
// this one survives it and dies only on the `Buffer` test.
{ target: "realtime", rule: "the decoded payload copies out of a Buffer, whose own slice is a view", old: " payload: new Uint8Array(bytes.subarray(offset, offset + payloadLength)),", new: " payload: bytes.slice(offset, offset + payloadLength)," },
// And the payload's type does not follow the frame's. This spelling copies, so
// both mutations above are dead to it, but `@@species` hands a `Buffer` frame a
// `Buffer` payload; only the `Buffer.isBuffer` assertion kills it.
{ target: "realtime", rule: "the decoded payload is a plain Uint8Array whatever the frame was", old: " payload: new Uint8Array(bytes.subarray(offset, offset + payloadLength)),", new: " payload: Uint8Array.prototype.slice.call(bytes, offset, offset + payloadLength)," },
// Last, because it removes this target's sentinel.
{ target: "realtime", rule: "the frame magic is 0x31545343", old: "const MAGIC = 0x31545343;", new: "const MAGIC = 0x31545344;" },
// The native bridge's runtime lifetime. Each rule gets two mutations: the
// revert, which puts back the spelling that was wrong, and a loosening, which
// keeps the shape and gives away the guarantee. A rule that only the revert
// kills is pinned by accident.
//
// `honestOn` is load-bearing. There is no ThreadSanitizer for MSVC, so the
// notify rule cannot be seen on win32-x64 at all; the quiesce rule is visible
// on both, but only because the assertion build states it outright. Without
// the assertion it is visible on neither in any dependable way: Soul's Cut 3
// pass hit the use-after-free 4 times in 48 linux-x64 AddressSanitizer
// attempts with pollers blocked mid-copy of large frames, and never once on
// release.
{
target: "native",
honestOn: ["linux-x64"],
rule: "the call scope notifies under `gate` (revert: notify after the unlock)",
old: " std::lock_guard<std::mutex> lock(runtime_->gate);\n" +
" --runtime_->active_calls;\n" +
" runtime_->signal.notify_all();\n",
new: " {\n" +
" std::lock_guard<std::mutex> lock(runtime_->gate);\n" +
" --runtime_->active_calls;\n" +
" }\n" +
" runtime_->signal.notify_all();\n",
},
{
target: "native",
honestOn: ["linux-x64"],
// The tempting spelling: notify only when this was the last call out, which
// looks like it narrows the window and does not close it at all. The closer's
// predicate is true the moment the count reaches zero, so this is the same
// race with a tidier face.
rule: "the call scope notifies under `gate` (loosening: notify outside it only when the count reaches zero)",
old: " std::lock_guard<std::mutex> lock(runtime_->gate);\n" +
" --runtime_->active_calls;\n" +
" runtime_->signal.notify_all();\n",
new: " bool last = false;\n" +
" {\n" +
" std::lock_guard<std::mutex> lock(runtime_->gate);\n" +
" last = (--runtime_->active_calls == 0);\n" +
" }\n" +
" if (last) runtime_->signal.notify_all();\n",
},
{
target: "native",
honestOn: ["linux-x64", "win32-x64"],
rule: "the close waits for every in-flight call (revert: wake without the wait)",
old: " runtime->signal.wait(lock, [runtime] { return runtime->active_calls == 0; });\n",
new: "",
},
{
target: "native",
honestOn: ["linux-x64", "win32-x64"],
// A bounded wait is the shape that survives review: it looks like the wait,
// it usually finishes, and it turns the contract into a hope. The assertion
// is what tells them apart.
rule: "the close waits for every in-flight call (loosening: a bounded wait that gives up)",
old: " runtime->signal.wait(lock, [runtime] { return runtime->active_calls == 0; });",
new: " runtime->signal.wait_for(lock, std::chrono::milliseconds(1),\n" +
" [runtime] { return runtime->active_calls == 0; });",
},
];
function digest(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function npm(workspace, ...args) {
execFileSync(process.execPath, [npmCli, ...args, "--workspace", workspace], {
cwd: repoRoot,
stdio: ["ignore", "pipe", "pipe"],
});
}
function testsPass(workspace) {
try {
npm(workspace, "run", "test");
return true;
} catch {
return false;
}
}
// The native killer. The bridge is a shared library, so "run the tests" is build
// the scenario runner beside it and run the scenarios. There is no npm here and
// no `dist/`; the artifacts live under artifacts/, which is git-ignored.
//
// Two configurations, because the two rules are visible to different things. The
// assertion build states the quiesce invariant outright, which is what makes the
// missing wait fail on the first close instead of on an unlucky one. The
// ThreadSanitizer build is the only thing that sees a notify land on a destroyed
// condition variable, and it exists on linux-x64 alone.
const nativeConfigurations = platform === "linux-x64"
? [
{ name: "asserts", asserts: "ON", sanitizer: null },
{ name: "tsan", asserts: "OFF", sanitizer: "thread" },
]
: [{ name: "asserts", asserts: "ON", sanitizer: null }];
function msquicArguments() {
if (platform === "win32-x64") {
const root = join(repoRoot, "artifacts", "dependencies", "msquic-openssl-2.5.9",
"package", "build", "native");
if (!existsSync(root))
throw new Error(`${root} is missing; run scripts/build-quic-native.ps1 once to fetch MsQuic`);
return {
configure: [`-DMSQUIC_ROOT=${root.replace(/\\/g, "/")}`, "-A", "x64"],
runtime: [[join(root, "bin", "x64", "msquic.dll"), "msquic.dll"]],
};
}
const cache = join(repoRoot, "artifacts", "dependencies", "msquic-linux-2.5.9");
const libraries = join(cache, "package", "usr", "lib", "x86_64-linux-gnu");
const versioned = join(libraries, "libmsquic.so.2.5.9");
if (!existsSync(versioned))
throw new Error(`${versioned} is missing; run scripts/build-quic-native.sh once to fetch MsQuic`);
// CMake links -lmsquic, which needs an unversioned name to resolve against;
// the deb ships only the versioned file.
writeFileSync(join(libraries, "libmsquic.so"), readFileSync(versioned));
return {
configure: [
`-DMSQUIC_INCLUDE_DIR=${join(cache, "include")}`,
`-DMSQUIC_LIB_DIR=${libraries}`,
],
runtime: [[versioned, "libmsquic.so.2"]],
};
}
function nativeScenariosPass() {
const msquic = msquicArguments();
const source = join(repoRoot, "native", "GameCult.Mesh.Quic.Native");
for (const configuration of nativeConfigurations) {
const build = join(repoRoot, "artifacts", "quic-native-mutation", platform, configuration.name);
// From scratch each time: a stale object file would let a mutation be
// reported against a binary that does not contain it.
rmSync(build, { recursive: true, force: true });
const flags = configuration.sanitizer
? [`-fsanitize=${configuration.sanitizer}`, "-fno-omit-frame-pointer", "-g"].join(" ")
: "";
// Multi-config generators (MSVC) put the binaries in a per-config directory;
// single-config ones (Ninja, Makefiles) do not.
const binaries = platform === "win32-x64"
? join(build, "bin", "RelWithDebInfo")
: join(build, "bin");
try {
execFileSync("cmake", [
"-S", source, "-B", build,
"-DCMAKE_BUILD_TYPE=RelWithDebInfo",
`-DCMAKE_CXX_FLAGS=${flags}`,
`-DCMAKE_EXE_LINKER_FLAGS=${flags}`,
`-DCMAKE_SHARED_LINKER_FLAGS=${flags}`,
"-DCULTMESH_QUIC_BUILD_TESTS=ON",
`-DCULTMESH_QUIC_DEBUG_ASSERTS=${configuration.asserts}`,
...msquic.configure,
], { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] });
execFileSync("cmake", ["--build", build, "--config", "RelWithDebInfo"],
{ cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] });
} catch {
// A mutation that does not compile is killed by the build, which is a
// legitimate kill: the rule it removed was load-bearing to the language.
return false;
}
for (const [from, name] of msquic.runtime) writeFileSync(join(binaries, name), readFileSync(from));
const runner = join(binaries,
platform === "win32-x64" ? "cultmesh_quic_native_tests.exe" : "cultmesh_quic_native_tests");
try {
execFileSync(runner, ["closerace", "20", "256"], {
cwd: binaries,
stdio: ["ignore", "pipe", "pipe"],
// Without this a reported race is printed and the run still exits 0,
// so every sanitizer mutation would survive.
env: { ...process.env, TSAN_OPTIONS: "halt_on_error=1", ASAN_OPTIONS: "halt_on_error=1" },
});
} catch {
return false;
}
}
return true;
}
function occurrences(text, needle) {
return text.split(needle).length - 1;
}
/**
* Repairs a file left mutated by a run that died before its `finally`, and only
* that.
*
* A SIGKILL leaves the sidecar behind. The repair used to write the sidecar over
* whatever the file now held, which is right after a kill and destructive after
* one: a fix made to the source between the kill and the next run was silently
* reverted to bytes from before the kill, and the run then reported on code
* nobody had written.
*
* So repair only what this script can prove it wrote. The mutation table is the
* record: every file state a live run can leave behind is either the original
* (the sidecar, also what the no-op control writes) or the original with exactly
* one of that target's mutations applied. Anything else — a hand edit, a
* half-written file, a different branch — is somebody else's, and the run stops
* with both digests rather than guessing.
*/
function repair(name, target, own, sidecar) {
const original = readFileSync(sidecar);
const originalText = original.toString("utf8");
const eol = originalText.includes("\r\n") ? "\r\n" : "\n";
const withEol = text => text.replace(/\r?\n/g, eol);
const current = readFileSync(target.file);
const currentDigest = digest(current);
const originalDigest = digest(original);
if (currentDigest === originalDigest || currentDigest === digest(Buffer.from(originalText, "utf8"))) {
unlinkSync(sidecar);
console.log(`${target.file} is already the original; dropped the stale ${sidecar}`);
return;
}
const currentText = current.toString("utf8");
const mutant = own.find(mutation =>
originalText.replace(withEol(mutation.old), withEol(mutation.new)) === currentText);
if (!mutant) {
throw new Error(
`${target.file} is neither the original nor any mutation of target '${name}', so a previous run did not write it.\n` +
` ${target.file} sha256=${currentDigest}\n` +
` ${sidecar} sha256=${originalDigest}\n` +
" Refusing to overwrite it. Reconcile the file by hand, then delete the sidecar.",
);
}
writeFileSync(target.file, original);
unlinkSync(sidecar);
console.log(`repaired ${target.file} from ${sidecar}: a previous run stopped inside '${mutant.rule}'`);
}
const results = [];
let failed = false;
// Named targets run only those; no argument runs all of them. The native target
// is built and run inside the Debian 13 container for its linux-x64 entries,
// where the TypeScript workspaces have no installed dependencies, so being able
// to ask for one target is what makes that run possible at all.
const requested = process.argv.slice(2);
for (const name of requested) {
if (!(name in targets)) throw new Error(`unknown target '${name}'; known: ${Object.keys(targets).join(", ")}`);
}
for (const [name, target] of Object.entries(targets)) {
if (requested.length > 0 && !requested.includes(name)) continue;
const own = mutations.filter(mutation => (mutation.target ?? "shared") === name);
if (own.length === 0) throw new Error(`target '${name}' has no mutations`);
const sidecar = `${target.file}.mutation-original`;
if (existsSync(sidecar)) repair(name, target, own, sidecar);
const original = readFileSync(target.file);
const originalText = original.toString("utf8");
const originalDigest = digest(original);
// Anchors are written with "\n"; the checkout may be CRLF (autocrlf). Match
// the file's own line ending so a multi-line anchor cannot silently miss.
const eol = originalText.includes("\r\n") ? "\r\n" : "\n";
const withEol = text => text.replace(/\r?\n/g, eol);
const restore = () => {
writeFileSync(target.file, original);
const restored = digest(readFileSync(target.file));
if (restored !== originalDigest) {
throw new Error(`restore failed: ${target.file} digest ${restored} != ${originalDigest}`);
}
};
// A target with a compiled `dist/` carries a sentinel so the post-run rebuild
// check can tell a rebuilt output from one still holding the last mutant. The
// native target has no dist/ and so has neither.
if (target.dist) {
const last = own[own.length - 1];
if (!originalText.includes(target.sentinel) ||
originalText.replace(withEol(last.old), withEol(last.new)).includes(target.sentinel)) {
throw new Error(
`the sentinel '${target.sentinel}' must be in ${target.file} and removed by its last mutation ('${last.rule}')`,
);
}
}
const check = target.check ?? (() => testsPass(target.workspace));
writeFileSync(sidecar, original);
try {
writeFileSync(target.file, Buffer.from(originalText, "utf8"));
const controlPass = check();
results.push({ target: name, outcome: controlPass ? "green" : "RED", rule: "control (no-op rewrite)" });
if (!controlPass) failed = true;
else {
for (const mutation of own) {
const [old, replacement] = [withEol(mutation.old), withEol(mutation.new)];
const count = occurrences(originalText, old);
if (count !== 1) {
throw new Error(`anchor for '${mutation.rule}' matched ${count} times in ${target.file}, expected exactly 1`);
}
// A mutation this platform cannot see is not run. Reporting it as killed
// from a run that never exercised it is the honesty failure these marks
// exist to prevent; the anchor is still checked above, so a rule whose
// spelling has moved fails here rather than going quiet.
if (mutation.honestOn && !mutation.honestOn.includes(platform)) {
results.push({
target: name,
outcome: "skipped",
rule: `${mutation.rule} — honest only on ${mutation.honestOn.join(", ")}, not ${platform}`,
});
continue;
}
writeFileSync(target.file, Buffer.from(originalText.replace(old, replacement), "utf8"));
const pass = check();
restore();
results.push({ target: name, outcome: pass ? "SURVIVED" : "killed", rule: mutation.rule });
if (pass) failed = true;
}
}
} finally {
// A verified restore is what the sidecar exists to guarantee, so it goes
// here and not after the try: a throw mid-run used to leave the sidecar
// behind, and the next run "repaired" the file from bytes that were already
// stale, silently undoing whatever had been fixed in between.
restore();
unlinkSync(sidecar);
}
if (!target.dist) {
results.push({ target: name, outcome: "restored", rule: `${target.file} sha256=${originalDigest}` });
continue;
}
// Every test run above rebuilt dist/ from a mutant; put the restored source back in it.
npm(target.workspace, "run", "build");
if (!readFileSync(target.dist, "utf8").includes(target.sentinel)) {
throw new Error(`${target.dist} does not contain '${target.sentinel}' after the rebuild; it still carries a mutant`);
}
results.push({
target: name,
outcome: "restored",
rule: `${target.file} sha256=${originalDigest}, rebuilt ${target.dist}`,
});
}
console.log(`build host and target: ${platform}`);
for (const result of results) console.log(`${result.outcome.padEnd(9)} [${result.target}] ${result.rule}`);
process.exit(failed ? 1 : 0);