[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
770
electron/bridges/zmodemFastPath.test.cjs
Normal file
770
electron/bridges/zmodemFastPath.test.cjs
Normal file
@@ -0,0 +1,770 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Tests for the zmodem.js Buffer fast path (zmodemFastPath.cjs).
|
||||
*
|
||||
* Strategy: the fast parser must be byte-for-byte equivalent to the
|
||||
* original zmodem.js subpacket parser, so most tests build wire bytes
|
||||
* with the library's own encoder and compare the fast parser against
|
||||
* Subpacket.parse16()/parse32(). Complete receive sessions
|
||||
* (ZFILE → ZDATA → ZEOF → ZFIN → OO) are then driven through both the
|
||||
* patched Session/Sentry and — via a fresh unpatched copy of the
|
||||
* library — the original array pipeline, to prove the state machine
|
||||
* wiring behaves identically.
|
||||
*/
|
||||
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
|
||||
const ZmodemLib = require("zmodem.js");
|
||||
const {
|
||||
applyZmodemFastPath,
|
||||
applyZmodemSendFastPath,
|
||||
applyZmodemSendSessionFixes,
|
||||
_internals: {
|
||||
parseSubpacketFast,
|
||||
zdleDecode,
|
||||
readDecodedBytes,
|
||||
stripIgnoredBytesFast,
|
||||
crc16Bytes,
|
||||
crc32Bytes,
|
||||
},
|
||||
} = require("./zmodemFastPath.cjs");
|
||||
|
||||
const Zmodem = applyZmodemFastPath(ZmodemLib);
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// helpers
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/** Deterministic PRNG so failures are reproducible. */
|
||||
function makeRng(seed) {
|
||||
let s = seed >>> 0;
|
||||
return function next() {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Random payload; every 4th byte is one of the interesting values
|
||||
* (ZDLE, XON/XOFF, CR, DEL, '@', 0xC0, NUL, LF).
|
||||
*/
|
||||
function randomPayload(rng, len) {
|
||||
const specials = [0x18, 0x11, 0x13, 0x0d, 0x7f, 0x40, 0xc0, 0x00, 0x0a];
|
||||
const out = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
const r = rng() & 0xff;
|
||||
out.push((r & 3) === 0 ? specials[r % specials.length] : r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const FRAME_ENDS = ["end_no_ack", "no_end_no_ack", "no_end_ack", "end_ack"];
|
||||
|
||||
/** Encode one subpacket with the library's own encoder. */
|
||||
function buildWire(payload, { crc32 = false, escCtl = false, frameEnd = "no_end_no_ack" } = {}) {
|
||||
const encoder = new Zmodem.ZDLE({ escape_ctrl_chars: escCtl });
|
||||
const subpacket = Zmodem.Subpacket.build(payload, frameEnd);
|
||||
return Buffer.from(
|
||||
crc32 ? subpacket.encode32(encoder) : subpacket.encode16(encoder),
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse a wire Buffer with the original library parser (array pipeline). */
|
||||
function parseWithOriginal(wireBuffer, crcLen) {
|
||||
const arr = Array.prototype.slice.call(wireBuffer); // what Sentry.consume() used to do
|
||||
const subpacket = crcLen === 2
|
||||
? Zmodem.Subpacket.parse16(arr)
|
||||
: Zmodem.Subpacket.parse32(arr);
|
||||
return {
|
||||
subpacket,
|
||||
consumed: wireBuffer.length - arr.length,
|
||||
payload: subpacket ? Array.from(subpacket.get_payload()) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a wire Buffer with the fast parser. */
|
||||
function parseWithFast(wireBuffer, crcLen) {
|
||||
const parsed = parseSubpacketFast(Zmodem, wireBuffer, crcLen);
|
||||
return {
|
||||
subpacket: parsed && parsed.subpacket,
|
||||
consumed: parsed ? parsed.consumed : null,
|
||||
payload: parsed ? Array.from(parsed.subpacket.get_payload()) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function assertParsersAgree(wireBuffer, crcLen, label) {
|
||||
const original = parseWithOriginal(wireBuffer, crcLen);
|
||||
const fast = parseWithFast(wireBuffer, crcLen);
|
||||
|
||||
assert.ok(original.subpacket, `${label}: original parser should parse`);
|
||||
assert.ok(fast.subpacket, `${label}: fast parser should parse`);
|
||||
assert.strictEqual(fast.consumed, original.consumed, `${label}: consumed`);
|
||||
assert.deepStrictEqual(fast.payload, original.payload, `${label}: payload`);
|
||||
assert.strictEqual(
|
||||
fast.subpacket.frame_end(),
|
||||
original.subpacket.frame_end(),
|
||||
`${label}: frame_end`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
fast.subpacket.ack_expected(),
|
||||
original.subpacket.ack_expected(),
|
||||
`${label}: ack_expected`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fake `sz` sender's wire for a complete download:
|
||||
* ZFILE + file-info subpacket, ZDATA + file subpackets, ZEOF, ZFIN.
|
||||
* Headers for ZFILE/ZDATA are binary (like lrzsz); ZEOF/ZFIN are hex.
|
||||
* The trailing "OO" + prompt is fed separately by the caller.
|
||||
*/
|
||||
function buildSenderWire(lib, filePayload, { crc32 = false } = {}) {
|
||||
const encoder = new lib.ZDLE({ escape_ctrl_chars: true });
|
||||
const enc = crc32 ? "encode32" : "encode16";
|
||||
const bin = crc32 ? "to_binary32" : "to_binary16";
|
||||
const chunks = [];
|
||||
|
||||
const zfileInfo = Array.from("fastpath-test.bin").map((c) => c.charCodeAt(0));
|
||||
zfileInfo.push(0);
|
||||
const rest = `${filePayload.length} 0 0 0`.split("").map((c) => c.charCodeAt(0));
|
||||
zfileInfo.push(...rest);
|
||||
|
||||
chunks.push(
|
||||
Buffer.from(
|
||||
lib.Header.build("ZFILE")[bin](encoder).concat(
|
||||
lib.Subpacket.build(zfileInfo, "end_ack")[enc](encoder),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const zdata = [lib.Header.build("ZDATA", 0)[bin](encoder)];
|
||||
const SUB = 1024;
|
||||
for (let off = 0; off < filePayload.length; off += SUB) {
|
||||
const slice = filePayload.slice(off, off + SUB);
|
||||
const atEnd = off + SUB >= filePayload.length;
|
||||
zdata.push(
|
||||
lib.Subpacket.build(slice, atEnd ? "end_no_ack" : "no_end_no_ack")[enc](encoder),
|
||||
);
|
||||
}
|
||||
chunks.push(Buffer.concat(zdata.map(Buffer.from)));
|
||||
|
||||
chunks.push(Buffer.from(lib.Header.build("ZEOF", filePayload.length).to_hex()));
|
||||
chunks.push(Buffer.from(lib.Header.build("ZFIN").to_hex()));
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/** Decode a sent header byte block into its header name. */
|
||||
function sentHeaderName(lib, bytes) {
|
||||
const parsed = lib.Header.parse(Array.prototype.slice.call(bytes));
|
||||
return parsed && parsed[0] && parsed[0].NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a second, unpatched copy of the zmodem.js classes by evicting the
|
||||
* library's modules from the require cache. The patched instance held by
|
||||
* this test file keeps working (its classes are separate objects).
|
||||
*/
|
||||
function requireFreshUnpatchedZmodem() {
|
||||
for (const key of Object.keys(require.cache)) {
|
||||
if (key.includes(`${require("node:path").sep}zmodem.js`)) {
|
||||
delete require.cache[key];
|
||||
}
|
||||
}
|
||||
return require("zmodem.js");
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// patch / idempotency
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
test("applyZmodemFastPath patches the shared prototypes and is idempotent", () => {
|
||||
assert.strictEqual(Zmodem.__netcattyZmodemFastPathApplied, true);
|
||||
assert.strictEqual(applyZmodemFastPath(Zmodem), Zmodem);
|
||||
assert.strictEqual(
|
||||
typeof Zmodem.Session.prototype._zmodem_fast_consume,
|
||||
"function",
|
||||
);
|
||||
});
|
||||
|
||||
test("fast receive detects an abort sequence spanning header and Buffer state", () => {
|
||||
const headerPrefix = Buffer.from([0x2a, 0x18, 0x41, 0x18, 0x18, 0x18]);
|
||||
const abortTail = Buffer.from([0x18, 0x18]);
|
||||
const session = new Zmodem.Session.Receive();
|
||||
|
||||
// An incomplete binary header is retained in _input_buffer. The remaining
|
||||
// two CAN bytes arrive in a later Buffer, so detection must cover both.
|
||||
session.consume(headerPrefix);
|
||||
assert.throws(
|
||||
() => session.consume(abortTail),
|
||||
(err) => err && err.type === "peer_aborted",
|
||||
);
|
||||
assert.equal(session.aborted(), true);
|
||||
});
|
||||
|
||||
test("fast receive checks abort bytes before post-ZFIN OO validation", () => {
|
||||
const session = new Zmodem.Session.Receive();
|
||||
session._got_ZFIN = true;
|
||||
|
||||
assert.throws(
|
||||
() => session.consume(Buffer.from([0x18, 0x18, 0x18, 0x18, 0x18])),
|
||||
(err) => err && err.type === "peer_aborted",
|
||||
);
|
||||
assert.equal(session.aborted(), true);
|
||||
assert.equal(session.has_ended(), true);
|
||||
});
|
||||
|
||||
test("fast parsed subpackets keep typed payloads for zero-copy downloads", () => {
|
||||
const wire = buildWire([0x00, 0x18, 0xff, 0x41], { frameEnd: "end_no_ack" });
|
||||
const parsed = parseSubpacketFast(Zmodem, wire, 2);
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.subpacket.get_payload() instanceof Uint8Array, true);
|
||||
});
|
||||
|
||||
test("kill switch disables both performance fast paths while retaining send-session fixes", () => {
|
||||
const previous = process.env.NETCATTY_ZMODEM_FAST_PATH;
|
||||
process.env.NETCATTY_ZMODEM_FAST_PATH = "0";
|
||||
|
||||
try {
|
||||
const fresh = requireFreshUnpatchedZmodem();
|
||||
const originalSentryConsume = fresh.Sentry.prototype.consume;
|
||||
const originalSendFilePart = fresh.Session.Send.prototype._send_file_part;
|
||||
|
||||
applyZmodemFastPath(fresh);
|
||||
applyZmodemSendSessionFixes(fresh);
|
||||
applyZmodemSendFastPath(fresh);
|
||||
|
||||
assert.equal(fresh.__netcattyZmodemFastPathApplied, undefined);
|
||||
assert.equal(fresh.__netcattyZmodemSendFastPathApplied, undefined);
|
||||
assert.equal(fresh.__netcattyZmodemSendSessionFixesApplied, true);
|
||||
assert.strictEqual(fresh.Sentry.prototype.consume, originalSentryConsume);
|
||||
assert.strictEqual(fresh.Session.Send.prototype._send_file_part, originalSendFilePart);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.NETCATTY_ZMODEM_FAST_PATH;
|
||||
else process.env.NETCATTY_ZMODEM_FAST_PATH = previous;
|
||||
}
|
||||
});
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// CRC
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
test("crc16Bytes matches the library's CRC.crc16", () => {
|
||||
const rng = makeRng(7);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const payload = randomPayload(rng, (rng() & 0x1ff) + 1);
|
||||
const frameEnd = 105;
|
||||
const lib = Zmodem.CRC.crc16(payload.concat([frameEnd]));
|
||||
const mine = crc16Bytes(payload, frameEnd);
|
||||
assert.strictEqual((mine >> 8) & 0xff, lib[0], `crc16 case ${i} hi`);
|
||||
assert.strictEqual(mine & 0xff, lib[1], `crc16 case ${i} lo`);
|
||||
}
|
||||
});
|
||||
|
||||
test("crc32Bytes matches the library's CRC.crc32", () => {
|
||||
const rng = makeRng(11);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const payload = randomPayload(rng, (rng() & 0x1ff) + 1);
|
||||
const frameEnd = 105;
|
||||
const lib = Zmodem.CRC.crc32(payload.concat([frameEnd]));
|
||||
const mine = crc32Bytes(payload, frameEnd);
|
||||
assert.deepStrictEqual(
|
||||
[mine & 0xff, (mine >> 8) & 0xff, (mine >> 16) & 0xff, (mine >> 24) & 0xff],
|
||||
lib,
|
||||
`crc32 case ${i}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// subpacket parser vs original
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
test("parseSubpacketFast round-trips CRC16 subpackets for every frame-end type", () => {
|
||||
const rng = makeRng(42);
|
||||
for (const frameEnd of FRAME_ENDS) {
|
||||
for (const escCtl of [false, true]) {
|
||||
const payload = randomPayload(rng, (rng() & 0x3ff) + 1);
|
||||
const wire = buildWire(payload, { frameEnd, escCtl });
|
||||
assertParsersAgree(wire, 2, `${frameEnd}/escCtl=${escCtl}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("parseSubpacketFast round-trips CRC32 subpackets for every frame-end type", () => {
|
||||
const rng = makeRng(43);
|
||||
for (const frameEnd of FRAME_ENDS) {
|
||||
const payload = randomPayload(rng, (rng() & 0x3ff) + 1);
|
||||
const wire = buildWire(payload, { frameEnd, crc32: true, escCtl: true });
|
||||
assertParsersAgree(wire, 4, `${frameEnd}/crc32`);
|
||||
}
|
||||
});
|
||||
|
||||
test("parseSubpacketFast handles empty and single-byte payloads", () => {
|
||||
for (const payload of [[], [0x18], [0x00], [0x41]]) {
|
||||
assertParsersAgree(buildWire(payload), 2, `payload=[${payload}]`);
|
||||
assertParsersAgree(buildWire(payload, { crc32: true }), 4, `payload=[${payload}] crc32`);
|
||||
}
|
||||
});
|
||||
|
||||
test("fuzz: byte-at-a-time feeding parses identically to the original", () => {
|
||||
const rng = makeRng(99);
|
||||
for (let round = 0; round < 30; round++) {
|
||||
const payload = randomPayload(rng, (rng() & 0xff) + 1);
|
||||
const crcLen = round % 2 === 0 ? 2 : 4;
|
||||
const wire = buildWire(payload, {
|
||||
crc32: crcLen === 4,
|
||||
escCtl: round % 3 === 0,
|
||||
frameEnd: FRAME_ENDS[round % FRAME_ENDS.length],
|
||||
});
|
||||
const original = parseWithOriginal(wire, crcLen);
|
||||
|
||||
// Feed the wire one byte at a time; the parser must return null
|
||||
// until the subpacket is complete, then parse it exactly once and
|
||||
// consume every byte.
|
||||
let acc = Buffer.alloc(0);
|
||||
let parsed = null;
|
||||
for (let i = 0; i < wire.length; i++) {
|
||||
acc = Buffer.concat([acc, wire.subarray(i, i + 1)]);
|
||||
const got = parseSubpacketFast(Zmodem, acc, crcLen);
|
||||
if (got) {
|
||||
assert.strictEqual(parsed, null, `round ${round}: parsed twice`);
|
||||
parsed = got;
|
||||
assert.strictEqual(got.consumed, acc.length, `round ${round}: partial consume`);
|
||||
assert.deepStrictEqual(
|
||||
Array.from(got.subpacket.get_payload()),
|
||||
original.payload,
|
||||
`round ${round}: payload`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
got.subpacket.frame_end(),
|
||||
original.subpacket.frame_end(),
|
||||
`round ${round}: frame_end`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.ok(parsed, `round ${round}: never parsed`);
|
||||
}
|
||||
});
|
||||
|
||||
test("CRC corruption throws the same 'crc' error as the library", () => {
|
||||
const rng = makeRng(5);
|
||||
for (const crcLen of [2, 4]) {
|
||||
for (const corruptWhat of ["payload", "crc"]) {
|
||||
const payload = randomPayload(rng, 200);
|
||||
const wire = buildWire(payload, { crc32: crcLen === 4 });
|
||||
|
||||
let at = -1;
|
||||
if (corruptWhat === "payload") {
|
||||
// flip the first byte that isn't a ZDLE escape prefix
|
||||
for (let i = 0; i < wire.length / 2; i++) {
|
||||
if (wire[i] !== 0x18) {
|
||||
at = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
at = wire.length - 1;
|
||||
}
|
||||
assert.ok(at !== -1);
|
||||
wire[at] ^= 0x40;
|
||||
|
||||
let libErr = null;
|
||||
let fastErr = null;
|
||||
try {
|
||||
parseWithOriginal(wire, crcLen);
|
||||
} catch (err) {
|
||||
libErr = err;
|
||||
}
|
||||
try {
|
||||
parseWithFast(wire, crcLen);
|
||||
} catch (err) {
|
||||
fastErr = err;
|
||||
}
|
||||
|
||||
assert.ok(libErr, `${crcLen}/${corruptWhat}: library should throw`);
|
||||
assert.ok(fastErr, `${crcLen}/${corruptWhat}: fast parser should throw`);
|
||||
assert.strictEqual(fastErr.type, "crc", `${crcLen}/${corruptWhat}: type`);
|
||||
assert.ok(
|
||||
fastErr.message.startsWith("CRC check failed!"),
|
||||
`${crcLen}/${corruptWhat}: message`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// low-level helpers vs the library
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
test("stripIgnoredBytesFast matches ZMLIB.strip_ignored_bytes", () => {
|
||||
const rng = makeRng(123);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
// sprinkle XON/XOFF (and high-bit variants) into random data
|
||||
const ignored = [0x11, 0x13, 0x91, 0x93];
|
||||
const input = randomPayload(rng, (rng() & 0x7f) + 1);
|
||||
for (let k = 0; k < 5; k++) {
|
||||
input[rng() % input.length] = ignored[rng() % ignored.length];
|
||||
}
|
||||
|
||||
const lib = input.slice(0);
|
||||
Zmodem.ZMLIB.strip_ignored_bytes(lib);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
Array.from(stripIgnoredBytesFast(Buffer.from(input))),
|
||||
lib,
|
||||
`case ${i}`,
|
||||
);
|
||||
}
|
||||
|
||||
// zero-copy on clean input
|
||||
const clean = Buffer.from([1, 2, 3]);
|
||||
assert.strictEqual(stripIgnoredBytesFast(clean), clean);
|
||||
});
|
||||
|
||||
test("zdleDecode matches ZDLE.decode", () => {
|
||||
const encoder = new Zmodem.ZDLE({ escape_ctrl_chars: false });
|
||||
const rng = makeRng(321);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const payload = randomPayload(rng, (rng() & 0x3ff) + 1);
|
||||
const encoded = encoder.encode(payload.slice(0)); // mutates its input
|
||||
const lib = Zmodem.ZDLE.decode(encoded.slice(0));
|
||||
assert.deepStrictEqual(Array.from(zdleDecode(Buffer.from(encoded))), lib, `case ${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("readDecodedBytes matches ZDLE.splice semantics", () => {
|
||||
const rng = makeRng(555);
|
||||
for (let i = 0; i < 40; i++) {
|
||||
// build an encoded byte array: mostly plain bytes, occasionally a
|
||||
// ZDLE escape pair (only second bytes >= 0x40 occur on the wire —
|
||||
// conforming encoders never emit 0x00-0x3F there); sometimes a bare
|
||||
// trailing ZDLE
|
||||
const raw = randomPayload(rng, (rng() & 0x1f) + 1);
|
||||
const encoded = [];
|
||||
for (const b of raw) {
|
||||
if ((b & 3) === 0) {
|
||||
encoded.push(0x18, 0x40 | (b & 0x3f));
|
||||
} else if ((b & 3) === 1) {
|
||||
encoded.push(0x18, 0xc0 | (b & 0x3f));
|
||||
} else {
|
||||
encoded.push(b);
|
||||
}
|
||||
}
|
||||
if ((i & 7) === 0) encoded.push(0x18); // bare trailing ZDLE
|
||||
|
||||
const count = 1 + (rng() & 7);
|
||||
|
||||
const libArr = encoded.slice(0);
|
||||
const lib = Zmodem.ZDLE.splice(libArr, 0, count);
|
||||
|
||||
const mine = readDecodedBytes(Buffer.from(encoded), 0, count);
|
||||
|
||||
if (lib === undefined) {
|
||||
assert.strictEqual(mine, null, `case ${i}: both incomplete`);
|
||||
} else {
|
||||
assert.ok(mine, `case ${i}: both complete`);
|
||||
assert.deepStrictEqual(Array.from(mine.bytes), lib, `case ${i}: bytes`);
|
||||
assert.strictEqual(mine.consumed, encoded.length - libArr.length, `case ${i}: consumed`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// complete receive sessions
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Drive a complete download through a receive session of `lib` (patched
|
||||
* or original), feeding `chunkSize` bytes at a time as Buffers (fast
|
||||
* path) or plain arrays (original pipeline, like the Sentry used to do).
|
||||
*
|
||||
* Returns everything observable, so two runs can be compared:
|
||||
* { sentNames, receivedHex, trailing, ended, aborted, firstError }.
|
||||
*/
|
||||
function runSessionScenario({ lib, filePayload, chunkSize, asBuffers, trailingFeed, crc32 = false }) {
|
||||
const fileBuffer = Buffer.from(filePayload);
|
||||
|
||||
const sent = [];
|
||||
const inputPayloads = [];
|
||||
const offers = [];
|
||||
let acceptPromise = null;
|
||||
|
||||
const session = new lib.Session.Receive();
|
||||
session.set_sender((octets) => sent.push(Buffer.from(octets)));
|
||||
session.on("offer", (xfer) => {
|
||||
offers.push(xfer);
|
||||
acceptPromise = xfer.accept({
|
||||
on_input(payload) {
|
||||
inputPayloads.push(Buffer.from(payload));
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
session.start();
|
||||
|
||||
const feed = (bytes) => {
|
||||
if (asBuffers) {
|
||||
session.consume(Buffer.from(bytes));
|
||||
} else {
|
||||
session.consume(Array.prototype.slice.call(bytes));
|
||||
}
|
||||
};
|
||||
|
||||
let firstError = null;
|
||||
const feedAll = (chunks) => {
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
feed(chunk);
|
||||
} catch (err) {
|
||||
if (!firstError) firstError = String((err && err.message) || err);
|
||||
break; // the session is broken from here on
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const wire = buildSenderWire(lib, filePayload, { crc32 });
|
||||
const wireChunks = [];
|
||||
for (let i = 0; i < wire.length; i += chunkSize) {
|
||||
wireChunks.push(wire.subarray(i, Math.min(i + chunkSize, wire.length)));
|
||||
}
|
||||
feedAll(wireChunks);
|
||||
feedAll(trailingFeed.map((piece) => Buffer.from(piece)));
|
||||
|
||||
return {
|
||||
offerCount: offers.length,
|
||||
offerName: offers.length ? offers[0].get_details().name : null,
|
||||
offerSize: offers.length ? offers[0].get_details().size : null,
|
||||
sentNames: sent.map((b) => sentHeaderName(lib, b)),
|
||||
receivedHex: Buffer.concat(inputPayloads).toString("hex"),
|
||||
trailing: session.has_ended() && !session.aborted()
|
||||
? Buffer.from(session.get_trailing_bytes()).toString()
|
||||
: null,
|
||||
ended: session.has_ended(),
|
||||
aborted: session.aborted(),
|
||||
firstError,
|
||||
acceptResolved: Boolean(acceptPromise),
|
||||
};
|
||||
}
|
||||
|
||||
const TRAILING_SPLIT = ["O", "Oprompt$ "]; // second O and the prompt together
|
||||
const TRAILING_WHOLE = ["OOprompt$ "];
|
||||
|
||||
test("patched receive session: full CRC16 download, 1 byte at a time", async () => {
|
||||
const r = runSessionScenario({
|
||||
lib: Zmodem,
|
||||
filePayload: randomPayload(makeRng(777), 3000),
|
||||
chunkSize: 1,
|
||||
asBuffers: true,
|
||||
trailingFeed: TRAILING_SPLIT,
|
||||
});
|
||||
assert.strictEqual(r.firstError, null);
|
||||
assert.strictEqual(r.offerCount, 1);
|
||||
assert.strictEqual(r.offerName, "fastpath-test.bin");
|
||||
assert.strictEqual(r.offerSize, 3000);
|
||||
assert.strictEqual(r.ended, true);
|
||||
assert.strictEqual(r.aborted, false);
|
||||
assert.strictEqual(r.trailing, "prompt$ ");
|
||||
assert.deepStrictEqual(r.sentNames, ["ZRINIT", "ZRPOS", "ZRINIT", "ZFIN"]);
|
||||
assert.strictEqual(r.receivedHex, Buffer.from(randomPayload(makeRng(777), 3000)).toString("hex"));
|
||||
});
|
||||
|
||||
test("patched receive session: full CRC32 download, OO in one chunk", () => {
|
||||
const r = runSessionScenario({
|
||||
lib: Zmodem,
|
||||
filePayload: randomPayload(makeRng(778), 2000),
|
||||
chunkSize: 7,
|
||||
asBuffers: true,
|
||||
trailingFeed: TRAILING_WHOLE,
|
||||
crc32: true,
|
||||
});
|
||||
assert.strictEqual(r.firstError, null);
|
||||
assert.strictEqual(r.ended, true);
|
||||
assert.strictEqual(r.trailing, "prompt$ ");
|
||||
assert.deepStrictEqual(r.sentNames, ["ZRINIT", "ZRPOS", "ZRINIT", "ZFIN"]);
|
||||
});
|
||||
|
||||
test("fast receive keeps fragmented packets segmented until a frame is complete", () => {
|
||||
const wire = buildWire(Array.from(Buffer.alloc(64 * 1024, 0x5a)), { frameEnd: "end_no_ack" });
|
||||
const session = new Zmodem.Session.Receive();
|
||||
session._last_header_crc = 16;
|
||||
session._next_subpacket_handler = () => {};
|
||||
|
||||
const originalConcat = Buffer.concat;
|
||||
let concatCount = 0;
|
||||
Buffer.concat = function countedConcat() {
|
||||
concatCount += 1;
|
||||
return originalConcat.apply(this, arguments);
|
||||
};
|
||||
|
||||
try {
|
||||
for (const byte of wire) session.consume(Buffer.from([byte]));
|
||||
} finally {
|
||||
Buffer.concat = originalConcat;
|
||||
}
|
||||
|
||||
assert.ok(concatCount <= 4, `fragmented packet was flattened ${concatCount} times`);
|
||||
});
|
||||
|
||||
test("fast receive keeps abort detection after compacting an incomplete CRC", () => {
|
||||
const wire = buildWire([0x41], { frameEnd: "end_no_ack" });
|
||||
const marker = wire.lastIndexOf(Buffer.from([0x18, 0x68]));
|
||||
assert.ok(marker >= 0);
|
||||
const session = new Zmodem.Session.Receive();
|
||||
session._last_header_crc = 16;
|
||||
session._next_subpacket_handler = () => {};
|
||||
|
||||
// Leave one CRC byte pending after the frame marker. This makes the fast
|
||||
// path compact its fragmented chunks before the remaining CRC and CAN
|
||||
// abort sequence arrive.
|
||||
for (let i = 0; i < marker + 3; i++) {
|
||||
session.consume(wire.subarray(i, i + 1));
|
||||
}
|
||||
assert.throws(
|
||||
() => session.consume(Buffer.from([wire[marker + 3], 0x18, 0x18, 0x18, 0x18, 0x18])),
|
||||
(err) => err && err.type === "peer_aborted",
|
||||
);
|
||||
assert.equal(session.aborted(), true);
|
||||
});
|
||||
|
||||
test("pending ZSINIT restores its ZACK handler before waiting", async () => {
|
||||
const lib = requireFreshUnpatchedZmodem();
|
||||
applyZmodemSendSessionFixes(lib);
|
||||
const session = new lib.Session.Send(
|
||||
lib.Header.build("ZRINIT", ["CANFDX", "CANOVIO"], 0),
|
||||
);
|
||||
session._zsinit_pending = true;
|
||||
session._next_header_handler = { ZRINIT() {} };
|
||||
|
||||
const pending = session._ensure_receiver_escapes_ctrl_chars();
|
||||
assert.equal(typeof session._next_header_handler.ZACK, "function");
|
||||
session._on_zsinit_ack();
|
||||
await pending;
|
||||
});
|
||||
|
||||
test("patched send file parts are wire-equivalent to the original", () => {
|
||||
const originalLib = requireFreshUnpatchedZmodem();
|
||||
const patchedLib = requireFreshUnpatchedZmodem();
|
||||
applyZmodemSendFastPath(patchedLib);
|
||||
|
||||
const payload = Buffer.alloc(8192 * 2 + 1);
|
||||
for (let i = 0; i < payload.length; i++) {
|
||||
payload[i] = (i * 73 + 19) & 0xff;
|
||||
}
|
||||
|
||||
function sendFilePart(lib) {
|
||||
const zrinit = lib.Header.build("ZRINIT", ["CANFDX", "CANOVIO"], 0);
|
||||
const session = new lib.Session.Send(zrinit);
|
||||
const wire = [];
|
||||
session.set_sender((bytes) => wire.push(Buffer.from(bytes)));
|
||||
session._stop_keepalive();
|
||||
session._sending_file = true;
|
||||
session._send_file_part(new Uint8Array(payload), "end_no_ack");
|
||||
return wire;
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(sendFilePart(patchedLib), sendFilePart(originalLib));
|
||||
});
|
||||
|
||||
test("session-level equivalence: fast path matches the original library pipeline", () => {
|
||||
const originalLib = requireFreshUnpatchedZmodem();
|
||||
assert.notStrictEqual(originalLib.Session.prototype.consume, Zmodem.Session.prototype.consume);
|
||||
|
||||
const filePayload = randomPayload(makeRng(1234), 2500);
|
||||
const scenarios = [
|
||||
{ chunkSize: 1, trailingFeed: TRAILING_SPLIT },
|
||||
{ chunkSize: 7, trailingFeed: TRAILING_WHOLE },
|
||||
{ chunkSize: 5, trailingFeed: TRAILING_SPLIT },
|
||||
// feed the trailing block as one chunk; exercises the trim-2 branch
|
||||
{ chunkSize: 3, trailingFeed: TRAILING_WHOLE },
|
||||
];
|
||||
|
||||
for (const s of scenarios) {
|
||||
const original = runSessionScenario({
|
||||
lib: originalLib,
|
||||
filePayload,
|
||||
chunkSize: s.chunkSize,
|
||||
asBuffers: false, // arrays, like the Sentry's old conversion
|
||||
trailingFeed: s.trailingFeed,
|
||||
});
|
||||
const fast = runSessionScenario({
|
||||
lib: Zmodem,
|
||||
filePayload,
|
||||
chunkSize: s.chunkSize,
|
||||
asBuffers: true,
|
||||
trailingFeed: s.trailingFeed,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(fast, original, `scenario ${JSON.stringify(s)}`);
|
||||
assert.strictEqual(fast.firstError, null, `scenario ${JSON.stringify(s)}: no errors`);
|
||||
}
|
||||
});
|
||||
|
||||
test("patched Sentry: detection, fast consume, trailing bytes to terminal", () => {
|
||||
const filePayload = randomPayload(makeRng(888), 2000);
|
||||
const fileBuffer = Buffer.from(filePayload);
|
||||
|
||||
const terminalBytes = [];
|
||||
const sent = [];
|
||||
let detection = null;
|
||||
let zsession = null;
|
||||
const inputPayloads = [];
|
||||
|
||||
const sentry = new Zmodem.Sentry({
|
||||
to_terminal(bytes) {
|
||||
terminalBytes.push(Buffer.from(bytes));
|
||||
},
|
||||
on_detect(det) {
|
||||
detection = det;
|
||||
},
|
||||
on_retract() {},
|
||||
sender(bytes) {
|
||||
sent.push(Buffer.from(bytes));
|
||||
},
|
||||
});
|
||||
|
||||
// the remote's `sz` starts with a ZRQINIT; the sentry detects it and
|
||||
// echoes the init bytes to the terminal, like Netcatty does today
|
||||
const zrqinitWire = Buffer.from(Zmodem.Header.build("ZRQINIT").to_hex());
|
||||
sentry.consume(zrqinitWire);
|
||||
assert.ok(detection, "detection fired");
|
||||
assert.strictEqual(
|
||||
Buffer.concat(terminalBytes).compare(zrqinitWire),
|
||||
0,
|
||||
"init bytes echoed to terminal",
|
||||
);
|
||||
|
||||
zsession = detection.confirm();
|
||||
assert.strictEqual(zsession.type, "receive");
|
||||
|
||||
// mimic handleDownload(): start the session, accept the offer
|
||||
zsession.start();
|
||||
zsession.on("offer", (xfer) => {
|
||||
xfer.accept({
|
||||
on_input(payload) {
|
||||
inputPayloads.push(Buffer.from(payload));
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const wire = buildSenderWire(Zmodem, filePayload);
|
||||
for (let i = 0; i < wire.length; i += 5) {
|
||||
sentry.consume(wire.subarray(i, Math.min(i + 5, wire.length)));
|
||||
}
|
||||
sentry.consume(Buffer.from("OOprompt$ "));
|
||||
|
||||
assert.strictEqual(zsession.has_ended(), true);
|
||||
assert.strictEqual(
|
||||
Buffer.concat(inputPayloads).compare(fileBuffer),
|
||||
0,
|
||||
"file reassembled",
|
||||
);
|
||||
|
||||
// after the session ends, the sentry routes trailing bytes to the
|
||||
// terminal again
|
||||
const allTerminal = Buffer.concat(terminalBytes).toString();
|
||||
assert.ok(allTerminal.includes("prompt$ "), "prompt went to terminal");
|
||||
assert.strictEqual(sentry.get_confirmed_session(), null);
|
||||
});
|
||||
Reference in New Issue
Block a user