Files
NetMesh/electron/bridges/zmodemFastPath.test.cjs

771 lines
26 KiB
JavaScript
Raw Permalink Normal View History

"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);
});