Two mutants the cut-2 pass recorded as equivalent were not. Both notes were reasoning, and both were wrong in the same way: they argued from the shape of the code instead of trying the input that breaks it. The decoder's length refusal reads `payloadLength < 0 || expected !== length`. The note said the first clause could never fire on its own, because a negative payload length makes the sum smaller than the frame. It does not: the sum also carries the three identity lengths, and a negative payload cancels against them. A 37-byte frame declaring `channelLength=65535, payloadLength=-65535` sums to exactly 37, matches its own byte length, and without the clause decodes to three blank identities and an empty payload where `CultMeshRealtimeWireProtocol` refuses it. Two smaller spellings do the same, `ch=1, pl=-1` over 37 bytes and `ch=2, pl=-1` over 38. No test covered that shape; one does now, and the mutation is listed and killed rather than excused. The payload copy was worse, because the fix and the bug looked identical. The decoder used `bytes.slice(...)`, which copies a `Uint8Array` — and the existing test passes one, so it was green — but Node's `Buffer` overrides `slice` with an alias of `subarray`. A `Buffer` is what a socket read hands a Node caller, which is exactly how the QUIC plane in cut 4 will reach this function, so every decoded payload would have been a live view of a frame buffer the transport is free to reuse. `Uint8Array.prototype.slice.call(bytes, ...)` was the obvious repair and does copy, but `%TypedArray%.prototype.slice` takes its result constructor from `@@species`, and `Buffer`'s is `FastBuffer`: the payload then comes back a `Buffer` from a `Buffer` frame and a `Uint8Array` from a `Uint8Array` one, so the same frame compares equal or unequal under `deepStrictEqual` depending on how it arrived. `new Uint8Array(bytes.subarray(...))` copies and is one type on both. Three mutations now guard the line — return a view, return a `Buffer` view, return a species-typed copy — and the first two die only on their own input type. Mutation runner: 75 mutations, 75 killed, 0 survivors, three green controls. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
478 lines
27 KiB
JavaScript
478 lines
27 KiB
JavaScript
// Mutation check for the CultMesh rules TypeScript owns: route verification and
|
|
// the realtime frame codec.
|
|
//
|
|
// 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`.
|
|
//
|
|
// node scripts/mutate-cultmesh.mjs
|
|
|
|
import { createHash } from "node:crypto";
|
|
import { execFileSync } from "node:child_process";
|
|
import { existsSync, readFileSync, 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");
|
|
|
|
// 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",
|
|
},
|
|
};
|
|
|
|
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;" },
|
|
];
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
for (const [name, target] of Object.entries(targets)) {
|
|
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}`);
|
|
}
|
|
};
|
|
|
|
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}')`,
|
|
);
|
|
}
|
|
|
|
writeFileSync(sidecar, original);
|
|
try {
|
|
writeFileSync(target.file, Buffer.from(originalText, "utf8"));
|
|
const controlPass = testsPass(target.workspace);
|
|
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`);
|
|
}
|
|
writeFileSync(target.file, Buffer.from(originalText.replace(old, replacement), "utf8"));
|
|
const pass = testsPass(target.workspace);
|
|
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);
|
|
}
|
|
|
|
// 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}`,
|
|
});
|
|
}
|
|
|
|
for (const result of results) console.log(`${result.outcome.padEnd(9)} [${result.target}] ${result.rule}`);
|
|
process.exit(failed ? 1 : 0);
|