771 lines
26 KiB
JavaScript
771 lines
26 KiB
JavaScript
|
|
"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);
|
||
|
|
});
|