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

506 lines
16 KiB
JavaScript
Raw Permalink Normal View History

const test = require("node:test");
const assert = require("node:assert/strict");
const {
IAC,
SE,
NOP,
SB,
WILL,
WONT,
DO,
DONT,
OPT,
SUBOPTION_IS,
SUBOPTION_SEND,
escapeIacForWire,
normalizeNvtNewlines,
createTelnetParser,
createTelnetNegotiator,
} = require("./telnetProtocol.cjs");
const collect = () => {
const data = [];
const commands = [];
const subnegs = [];
return {
data,
commands,
subnegs,
parser: createTelnetParser({
onData(buf) {
data.push(Buffer.from(buf));
},
onCommand(cmd, opt) {
commands.push({ cmd, opt });
},
onSubnegotiation(opt, payload) {
subnegs.push({ opt, payload: Buffer.from(payload) });
},
}),
};
};
test("escapeIacForWire — passthrough when no 0xFF byte", () => {
const input = Buffer.from([0x61, 0x62, 0x63]);
assert.equal(escapeIacForWire(input), input);
});
test("escapeIacForWire — doubles each 0xFF", () => {
const input = Buffer.from([0xff, 0x61, 0xff, 0xff, 0x62]);
const got = escapeIacForWire(input);
assert.deepEqual(
[...got],
[0xff, 0xff, 0x61, 0xff, 0xff, 0xff, 0xff, 0x62],
);
});
test("normalizeNvtNewlines converts local newlines to Telnet NVT form", () => {
assert.equal(normalizeNvtNewlines("ps\r"), "ps\r\n");
assert.equal(normalizeNvtNewlines("ps\n"), "ps\r\n");
assert.equal(normalizeNvtNewlines("ps\r\n"), "ps\r\n");
assert.equal(normalizeNvtNewlines("red\r\0raw"), "red\r\0raw");
});
test("parser emits clean data when no IAC bytes are present", () => {
const { parser, data, commands, subnegs } = collect();
parser.feed(Buffer.from("hello world"));
assert.equal(Buffer.concat(data).toString("utf8"), "hello world");
assert.equal(commands.length, 0);
assert.equal(subnegs.length, 0);
});
test("parser handles a complete DO option command in one feed", () => {
const { parser, data, commands } = collect();
parser.feed(Buffer.from([IAC, DO, OPT.SUPPRESS_GO_AHEAD]));
assert.equal(data.length, 0);
assert.deepEqual(commands, [{ cmd: DO, opt: OPT.SUPPRESS_GO_AHEAD }]);
});
test("parser splits clean data around an option command", () => {
const { parser, data, commands } = collect();
parser.feed(
Buffer.concat([
Buffer.from("login: "),
Buffer.from([IAC, WILL, OPT.ECHO]),
Buffer.from("admin"),
]),
);
assert.equal(Buffer.concat(data).toString("utf8"), "login: admin");
assert.deepEqual(commands, [{ cmd: WILL, opt: OPT.ECHO }]);
});
test("parser unescapes IAC IAC into a literal 0xFF in the data stream", () => {
const { parser, data } = collect();
parser.feed(Buffer.from([0x61, IAC, IAC, 0x62]));
assert.deepEqual([...Buffer.concat(data)], [0x61, 0xff, 0x62]);
});
test("parser ignores stand-alone IAC verbs (NOP)", () => {
const { parser, data, commands } = collect();
parser.feed(Buffer.from([0x61, IAC, NOP, 0x62]));
assert.deepEqual([...Buffer.concat(data)], [0x61, 0x62]);
assert.equal(commands.length, 0);
});
test("parser parses a complete subnegotiation in one feed", () => {
const { parser, data, subnegs } = collect();
// IAC SB TERMINAL_TYPE IS "XTERM" IAC SE
parser.feed(
Buffer.concat([
Buffer.from([IAC, SB, OPT.TERMINAL_TYPE, 0]),
Buffer.from("XTERM"),
Buffer.from([IAC, SE]),
]),
);
assert.equal(data.length, 0);
assert.equal(subnegs.length, 1);
assert.equal(subnegs[0].opt, OPT.TERMINAL_TYPE);
assert.deepEqual(
[...subnegs[0].payload],
[0, 0x58, 0x54, 0x45, 0x52, 0x4d],
);
});
test("parser tolerates IAC IAC inside a subnegotiation payload", () => {
const { parser, subnegs } = collect();
// SB STATUS 0xFF (encoded as IAC IAC) 0x01 SE
parser.feed(Buffer.from([IAC, SB, OPT.STATUS, IAC, IAC, 0x01, IAC, SE]));
assert.equal(subnegs.length, 1);
assert.deepEqual([...subnegs[0].payload], [0xff, 0x01]);
});
test("parser preserves a lone IAC at end-of-chunk for the next feed", () => {
const { parser, data, commands } = collect();
parser.feed(Buffer.concat([Buffer.from("hi"), Buffer.from([IAC])]));
assert.equal(Buffer.concat(data).toString("utf8"), "hi");
assert.equal(commands.length, 0);
assert.equal(parser.pendingByteCount, 1);
// Next chunk completes the command.
parser.feed(Buffer.from([DO, OPT.NAWS, 0x61]));
assert.equal(parser.pendingByteCount, 0);
assert.deepEqual(commands, [{ cmd: DO, opt: OPT.NAWS }]);
// The trailing 'a' must have been emitted as data.
assert.equal(Buffer.concat(data).toString("utf8"), "hia");
});
test("parser preserves a half-finished option command (IAC DO) for the next feed", () => {
const { parser, data, commands } = collect();
parser.feed(Buffer.from([0x61, IAC, DO]));
assert.equal(Buffer.concat(data).toString("utf8"), "a");
assert.equal(commands.length, 0);
assert.equal(parser.pendingByteCount, 2);
parser.feed(Buffer.from([OPT.TERMINAL_TYPE, 0x62]));
assert.deepEqual(commands, [{ cmd: DO, opt: OPT.TERMINAL_TYPE }]);
assert.equal(Buffer.concat(data).toString("utf8"), "ab");
});
test("parser preserves an unterminated subnegotiation across multiple frames", () => {
const { parser, data, subnegs } = collect();
// Send IAC SB TT 0 "XTE" — the SE is intentionally missing.
parser.feed(
Buffer.concat([
Buffer.from("prefix"),
Buffer.from([IAC, SB, OPT.TERMINAL_TYPE, 0]),
Buffer.from("XTE"),
]),
);
assert.equal(Buffer.concat(data).toString("utf8"), "prefix");
assert.equal(subnegs.length, 0);
// Now the remaining payload + IAC SE arrive together with trailing data.
parser.feed(
Buffer.concat([
Buffer.from("RM-256COLOR"),
Buffer.from([IAC, SE]),
Buffer.from(" tail"),
]),
);
assert.equal(subnegs.length, 1);
assert.equal(subnegs[0].opt, OPT.TERMINAL_TYPE);
assert.deepEqual(
Buffer.from(subnegs[0].payload).toString("utf8"),
"\x00XTERM-256COLOR",
);
assert.equal(Buffer.concat(data).toString("utf8"), "prefix tail");
});
test("parser does not leak SB payload as data when the SE never arrives", () => {
// Regression: in the old stateless implementation, an unterminated SB block
// would fall through to "skip IAC SB and emit the rest as data" — leaking
// option-type names and other text into the terminal.
const { parser, data, subnegs } = collect();
parser.feed(
Buffer.concat([
Buffer.from([IAC, SB, OPT.TERMINAL_TYPE, 0]),
Buffer.from("XTERM-PARTIAL"),
]),
);
assert.equal(data.length, 0);
assert.equal(subnegs.length, 0);
assert.ok(parser.pendingByteCount > 0);
});
test("parser handles two consecutive option commands without losing either", () => {
const { parser, commands } = collect();
parser.feed(
Buffer.from([IAC, WILL, OPT.ECHO, IAC, DO, OPT.SUPPRESS_GO_AHEAD]),
);
assert.deepEqual(commands, [
{ cmd: WILL, opt: OPT.ECHO },
{ cmd: DO, opt: OPT.SUPPRESS_GO_AHEAD },
]);
});
test("parser feed is no-op for empty / null chunks", () => {
const { parser, data, commands } = collect();
parser.feed(Buffer.alloc(0));
parser.feed(null);
parser.feed(undefined);
assert.equal(data.length, 0);
assert.equal(commands.length, 0);
});
test("parser reset clears pending state", () => {
const { parser } = collect();
parser.feed(Buffer.from([IAC]));
assert.equal(parser.pendingByteCount, 1);
parser.reset();
assert.equal(parser.pendingByteCount, 0);
});
const recordNegotiator = (overrides = {}) => {
const commands = [];
const subnegs = [];
const negotiator = createTelnetNegotiator({
writeCommand(cmd, opt) {
commands.push({ cmd, opt });
},
writeSubnegotiation(opt, payload) {
subnegs.push({ opt, payload: Buffer.from(payload) });
},
getWindowSize: () => ({ cols: 120, rows: 40 }),
...overrides,
});
return { negotiator, commands, subnegs };
};
test("negotiator.start drives the canonical handshake (DO SGA / WILL TT / WILL NAWS)", () => {
const { negotiator, commands } = recordNegotiator();
negotiator.start();
assert.deepEqual(commands, [
{ cmd: DO, opt: OPT.SUPPRESS_GO_AHEAD },
{ cmd: WILL, opt: OPT.TERMINAL_TYPE },
{ cmd: WILL, opt: OPT.NAWS },
]);
assert.equal(negotiator.pendingDoCount, 1);
assert.equal(negotiator.pendingWillCount, 2);
});
test("peer's WILL on our pending DO is swallowed (no double-DO loop)", () => {
const { negotiator, commands } = recordNegotiator();
negotiator.start();
commands.length = 0;
// Server replies WILL SGA — acknowledges our DO SGA.
negotiator.handleCommand(WILL, OPT.SUPPRESS_GO_AHEAD);
assert.deepEqual(commands, []);
assert.equal(negotiator.pendingDoCount, 0);
});
test("peer's independent DO on a SGA where our DO is still pending is replied with WILL (regression)", () => {
// RFC 858: WILL/WONT and DO/DONT are independent per direction. The peer
// can ask us to enable SGA on our side while our request to enable it on
// its side is still in flight. The old implementation incorrectly treated
// the peer's DO as an ack of our DO and never replied.
const { negotiator, commands } = recordNegotiator();
negotiator.start();
commands.length = 0;
negotiator.handleCommand(DO, OPT.SUPPRESS_GO_AHEAD);
assert.deepEqual(commands, [{ cmd: WILL, opt: OPT.SUPPRESS_GO_AHEAD }]);
// The pending DO request stays open until the peer also says WILL/WONT.
assert.equal(negotiator.pendingDoCount, 1);
});
test("peer's DO NAWS that acknowledges our WILL NAWS still triggers a size subnegotiation", () => {
const { negotiator, commands, subnegs } = recordNegotiator();
negotiator.start();
commands.length = 0;
subnegs.length = 0;
negotiator.handleCommand(DO, OPT.NAWS);
// No echoed WILL NAWS — the peer was acknowledging our own WILL.
assert.deepEqual(commands, []);
// But the actual size payload must follow.
assert.equal(subnegs.length, 1);
assert.equal(subnegs[0].opt, OPT.NAWS);
assert.deepEqual(
[...subnegs[0].payload],
[(120 >> 8) & 0xff, 120 & 0xff, (40 >> 8) & 0xff, 40 & 0xff],
);
assert.equal(negotiator.pendingWillCount, 1); // TERMINAL-TYPE still outstanding
});
test("peer's independent DO NAWS (no WILL pending) replies WILL + size subneg", () => {
const { negotiator, commands, subnegs } = recordNegotiator();
// Note: not calling start(), so no WILL NAWS is pending.
negotiator.handleCommand(DO, OPT.NAWS);
assert.deepEqual(commands, [{ cmd: WILL, opt: OPT.NAWS }]);
assert.equal(subnegs.length, 1);
assert.equal(subnegs[0].opt, OPT.NAWS);
});
test("sendWindowSize is silent until the peer enables NAWS", () => {
const { negotiator, subnegs } = recordNegotiator();
negotiator.start();
const sent = negotiator.sendWindowSize();
assert.equal(sent, false);
assert.equal(subnegs.length, 0);
});
test("peer's repeated DO for enabled local options is ignored", () => {
const { negotiator, commands, subnegs } = recordNegotiator();
negotiator.handleCommand(DO, OPT.NAWS);
negotiator.handleCommand(DO, OPT.NAWS);
negotiator.handleCommand(DO, OPT.SUPPRESS_GO_AHEAD);
negotiator.handleCommand(DO, OPT.SUPPRESS_GO_AHEAD);
assert.deepEqual(commands, [
{ cmd: WILL, opt: OPT.NAWS },
{ cmd: WILL, opt: OPT.SUPPRESS_GO_AHEAD },
]);
assert.equal(subnegs.length, 1);
});
test("peer's DO/DONT ECHO controls client local echo", () => {
const states = [];
const { negotiator, commands } = recordNegotiator({
onLocalEchoChange: (enabled) => states.push(enabled),
});
negotiator.handleCommand(DO, OPT.ECHO);
negotiator.handleCommand(DO, OPT.ECHO);
negotiator.handleCommand(DONT, OPT.ECHO);
negotiator.handleCommand(DONT, OPT.ECHO);
assert.deepEqual(states, [true, false]);
assert.deepEqual(commands, [
{ cmd: WILL, opt: OPT.ECHO },
{ cmd: WONT, opt: OPT.ECHO },
]);
});
test("peer's WILL ECHO triggers DO ECHO, repeated WILL ECHO is ignored", () => {
const { negotiator, commands } = recordNegotiator();
negotiator.handleCommand(WILL, OPT.ECHO);
negotiator.handleCommand(WILL, OPT.ECHO);
assert.deepEqual(commands, [{ cmd: DO, opt: OPT.ECHO }]);
});
test("peer's WILL ECHO disables client local echo", () => {
const states = [];
const { negotiator, commands } = recordNegotiator({
onLocalEchoChange: (enabled) => states.push(enabled),
});
negotiator.handleCommand(DO, OPT.ECHO);
commands.length = 0;
negotiator.handleCommand(WILL, OPT.ECHO);
assert.deepEqual(states, [true, false]);
assert.deepEqual(commands, [
{ cmd: WONT, opt: OPT.ECHO },
{ cmd: DO, opt: OPT.ECHO },
]);
});
test("peer's ECHO negotiation publishes remote echo state", () => {
const states = [];
const { negotiator } = recordNegotiator({
onRemoteEchoChange: (enabled) => states.push(enabled),
});
negotiator.handleCommand(WILL, OPT.ECHO);
negotiator.handleCommand(WONT, OPT.ECHO);
assert.deepEqual(states, [true, false]);
});
test("peer's WONT on our pending DO is swallowed", () => {
const { negotiator, commands } = recordNegotiator();
negotiator.start();
commands.length = 0;
negotiator.handleCommand(WONT, OPT.SUPPRESS_GO_AHEAD);
assert.deepEqual(commands, []);
assert.equal(negotiator.pendingDoCount, 0);
});
test("peer's repeated WONT ECHO is not acknowledged in a loop", () => {
const { negotiator, commands } = recordNegotiator();
negotiator.handleCommand(WILL, OPT.ECHO);
commands.length = 0;
negotiator.handleCommand(WONT, OPT.ECHO);
negotiator.handleCommand(WONT, OPT.ECHO);
assert.deepEqual(commands, [{ cmd: DONT, opt: OPT.ECHO }]);
});
test("peer's DONT on our pending WILL is swallowed", () => {
const { negotiator, commands } = recordNegotiator();
negotiator.start();
commands.length = 0;
negotiator.handleCommand(DONT, OPT.TERMINAL_TYPE);
assert.deepEqual(commands, []);
// NAWS still outstanding.
assert.equal(negotiator.pendingWillCount, 1);
});
test("peer's repeated DONT for enabled local options is not acknowledged in a loop", () => {
const { negotiator, commands } = recordNegotiator();
negotiator.handleCommand(DO, OPT.SUPPRESS_GO_AHEAD);
commands.length = 0;
negotiator.handleCommand(DONT, OPT.SUPPRESS_GO_AHEAD);
negotiator.handleCommand(DONT, OPT.SUPPRESS_GO_AHEAD);
assert.deepEqual(commands, [{ cmd: WONT, opt: OPT.SUPPRESS_GO_AHEAD }]);
});
test("peer's DO on an option we don't support replies WONT", () => {
const { negotiator, commands } = recordNegotiator();
negotiator.handleCommand(DO, OPT.LINEMODE);
assert.deepEqual(commands, [{ cmd: WONT, opt: OPT.LINEMODE }]);
});
test("peer's WILL on an option we don't support replies DONT", () => {
const { negotiator, commands } = recordNegotiator();
negotiator.handleCommand(WILL, OPT.NEW_ENVIRON);
assert.deepEqual(commands, [{ cmd: DONT, opt: OPT.NEW_ENVIRON }]);
});
test("peer's TERMINAL-TYPE SEND subnegotiation replies with IS <termType>", () => {
const { negotiator, subnegs } = recordNegotiator();
negotiator.handleSubnegotiation(
OPT.TERMINAL_TYPE,
Buffer.from([SUBOPTION_SEND]),
);
assert.equal(subnegs.length, 1);
assert.equal(subnegs[0].opt, OPT.TERMINAL_TYPE);
assert.equal(
subnegs[0].payload.toString("ascii"),
"\x00XTERM-256COLOR",
);
});
test("negotiator honors a custom termType override", () => {
const { negotiator, subnegs } = recordNegotiator({ termType: "VT100" });
negotiator.handleSubnegotiation(
OPT.TERMINAL_TYPE,
Buffer.from([SUBOPTION_SEND]),
);
assert.equal(subnegs[0].payload.toString("ascii"), "\x00VT100");
});
test("sendWindowSize falls back to 80x24 when getWindowSize returns garbage", () => {
const { negotiator, subnegs } = recordNegotiator({
getWindowSize: () => ({ cols: NaN, rows: -3 }),
});
negotiator.handleCommand(DO, OPT.NAWS);
subnegs.length = 0;
const sent = negotiator.sendWindowSize();
assert.equal(sent, true);
assert.deepEqual([...subnegs[0].payload], [0, 80, 0, 24]);
});
test("data emitted before a command is delivered before that command's callback", () => {
const order = [];
const parser = createTelnetParser({
onData(buf) {
order.push(`data:${buf.toString("utf8")}`);
},
onCommand(cmd, opt) {
order.push(`cmd:${cmd}:${opt}`);
},
});
parser.feed(
Buffer.concat([
Buffer.from("hi"),
Buffer.from([IAC, WONT, OPT.LINEMODE]),
Buffer.from("bye"),
]),
);
assert.deepEqual(order, [
"data:hi",
`cmd:${WONT}:${OPT.LINEMODE}`,
"data:bye",
]);
});