From 296f0ce309e402acc18deeb0f52f7b05b6496631 Mon Sep 17 00:00:00 2001 From: Ivan Z Date: Mon, 31 Aug 2026 01:38:57 -0700 Subject: [PATCH] fix: Adding validation for raw data size parsing --- trimsock.gd/.gitignore | 2 + trimsock.gd/addons/trimsock.gd/line_parser.gd | 3 + trimsock.gd/addons/trimsock.gd/line_reader.gd | 22 ++- trimsock.gd/addons/trimsock.gd/reactor.gd | 10 +- trimsock.gd/addons/trimsock.gd/reader.gd | 91 +++++++++-- trimsock.gd/tests/reactor.test.gd | 18 ++ trimsock.gd/tests/reader.test.gd | 131 +++++++++++++++ trimsock.js/.gitignore | 1 + .../trimsock-bun/lib/bun.socket.reactor.ts | 2 + .../packages/trimsock-js/lib/reader.ts | 66 +++++++- .../trimsock-js/spec/reactor/reactor.spec.ts | 29 ++++ .../packages/trimsock-js/spec/reader.spec.ts | 154 +++++++++++++++++- .../trimsock-node/lib/node.socket.reactor.ts | 2 + 13 files changed, 499 insertions(+), 32 deletions(-) diff --git a/trimsock.gd/.gitignore b/trimsock.gd/.gitignore index f51cb86..7a17a33 100644 --- a/trimsock.gd/.gitignore +++ b/trimsock.gd/.gitignore @@ -3,3 +3,5 @@ # Only for local use sh/ensure-uids.sh + +.vscode/ diff --git a/trimsock.gd/addons/trimsock.gd/line_parser.gd b/trimsock.gd/addons/trimsock.gd/line_parser.gd index 5dc0e86..d7e3389 100644 --- a/trimsock.gd/addons/trimsock.gd/line_parser.gd +++ b/trimsock.gd/addons/trimsock.gd/line_parser.gd @@ -34,6 +34,9 @@ func parse(p_line: String) -> TrimsockCommand: return command func read_name() -> String: + if is_eol(): + return "" + if chr() == "\"": return read_quoted() else: diff --git a/trimsock.gd/addons/trimsock.gd/line_reader.gd b/trimsock.gd/addons/trimsock.gd/line_reader.gd index fabb6bb..825c975 100644 --- a/trimsock.gd/addons/trimsock.gd/line_reader.gd +++ b/trimsock.gd/addons/trimsock.gd/line_reader.gd @@ -7,10 +7,16 @@ var at := 0 var is_quote := false var is_escape := false +func reset() -> void: + buffer.clear() + at = 0 + is_quote = false + is_escape = false + func ingest(data: PackedByteArray) -> Error: var new_size := buffer.size() + data.size() if new_size > max_size: - buffer.clear() + reset() return ERR_OUT_OF_MEMORY buffer.append_array(data) @@ -30,20 +36,26 @@ func read_text() -> String: return "" func has_data(size: int) -> bool: - return buffer.size() >= size + return buffer.size() > size -func read_data(size: int) -> PackedByteArray: +func read_data(size: int) -> Array: assert(has_data(size), "Trying to read more bytes than available!") # Grab result var result := buffer.slice(0, size) - buffer = buffer.slice(size) + var is_terminated := String.chr(buffer[size]) == "\n" + + buffer = buffer.slice(size + 1) + at = 0 # Reset flags is_escape = false is_quote = false - return result + if not is_terminated: + return [ERR_PARSE_ERROR, PackedByteArray()] + + return [OK, result] func chr() -> String: return String.chr(buffer[at]) diff --git a/trimsock.gd/addons/trimsock.gd/reactor.gd b/trimsock.gd/addons/trimsock.gd/reactor.gd index b80bc29..e77e845 100644 --- a/trimsock.gd/addons/trimsock.gd/reactor.gd +++ b/trimsock.gd/addons/trimsock.gd/reactor.gd @@ -37,6 +37,8 @@ var _id_generator: TrimsockIDGenerator = RandomTrimsockIDGenerator.new(12) signal on_attach(source: Variant) ## Emitted when a known source is detached from the reactor signal on_detach(source: Variant) +## Emitted when data received from a source can't be parsed +signal on_ingest_error(source: Variant, error: Error, message: String) ## Poll all sources and process incoming data @@ -48,6 +50,8 @@ func poll() -> void: while true: var command := reader.read() if not command: + if reader.last_error != OK: + on_ingest_error.emit(source, reader.last_error, reader.last_error_message) break _handle(command, source) @@ -155,7 +159,11 @@ func _write(target: Variant, command: TrimsockCommand) -> void: func _ingest(source: Variant, data: PackedByteArray) -> Error: assert(_readers.has(source), "Ingesting data from unknown source! Did you call `attach()`?") var reader := _readers[source] as TrimsockReader - return reader.ingest_bytes(data) + var error := reader.ingest_bytes(data) + if error != OK: + on_ingest_error.emit(source, error, reader.last_error_message) + + return error func _handle(command: TrimsockCommand, source: Variant) -> void: var xchg := _get_exchange_for(command, source) diff --git a/trimsock.gd/addons/trimsock.gd/reader.gd b/trimsock.gd/addons/trimsock.gd/reader.gd index 6cc7b3c..2edd1bd 100644 --- a/trimsock.gd/addons/trimsock.gd/reader.gd +++ b/trimsock.gd/addons/trimsock.gd/reader.gd @@ -9,31 +9,62 @@ class_name TrimsockReader ## [br][br] ## After ingestion, call [method read] to extract commands from the ingested ## data. Data that is parsed is immediately freed from the internal buffer. +## [br][br] +## If the incoming data is malformed, it is discarded and the error is reported +## in [member last_error]. The reader continues parsing with the data following +## the offending command. + +## Upper limit on the internal buffer's size +## +## Ingested data is buffered until [method read] can extract a full command +## from it. If this size limit is exceeded, the buffer's contents are discarded +## and [constant ERR_OUT_OF_MEMORY] is reported. +var max_size: int: + get: + return _line_reader.max_size + set(value): + _line_reader.max_size = value + +var last_error: Error = OK +## Description of [member last_error] +var last_error_message := "" var _line_reader: _TrimsockLineReader = _TrimsockLineReader.new() var _line_parser: _TrimsockLineParser = _TrimsockLineParser.new() var _queued_raw: TrimsockCommand = null +var _queued_raw_size := -1 ## Ingest incoming text ## [br][br] ## Returns [constant OK] on success, or [constant ERR_OUT_OF_MEMORY] if the ## internal buffer can't store the data. func ingest_text(text: String) -> Error: - return _line_reader.ingest(text.to_utf8_buffer()) + return ingest_bytes(text.to_utf8_buffer()) ## Ingest incoming binary data ## [br][br] ## Returns [constant OK] on success, or [constant ERR_OUT_OF_MEMORY] if the ## internal buffer can't store the data. func ingest_bytes(bytes: PackedByteArray) -> Error: - return _line_reader.ingest(bytes) + _clear_error() + + var error := _line_reader.ingest(bytes) + if error != OK: + _dequeue_raw() + _set_error(error, "Buffer overflow! Can't ingest %d bytes without exceeding %d!" \ + % [bytes.size(), max_size]) + + return error ## Try and extract a command from the ingested data ## [br][br] ## Returns a parsed command, or [code]null[/code] if no command is available -## yet. +## yet. Malformed data is rejected by returning [code]null[/code] and setting +## [member last_error]. func read() -> TrimsockCommand: + _clear_error() + var command := _pop() if command: _TrimsockConventions.apply(command) @@ -42,17 +73,24 @@ func read() -> TrimsockCommand: func _pop() -> TrimsockCommand: # We read a raw command earlier, waiting to have enough data if _queued_raw: - var data_size := int(_queued_raw.text) - if not _line_reader.has_data(data_size): + if not _line_reader.has_data(_queued_raw_size): + return null + + var raw_command := _queued_raw + var raw_size := _queued_raw_size + var read_result := _line_reader.read_data(raw_size) + + _dequeue_raw() + + if read_result[0] != OK: + _set_error(read_result[0], "Expected newline after %d bytes of raw data!" % [raw_size]) return null - _queued_raw.raw = _line_reader.read_data(data_size) - _queued_raw.text = "" - _queued_raw.chunks.clear() + raw_command.raw = read_result[1] + raw_command.text = "" + raw_command.chunks.clear() - var result := _queued_raw - _queued_raw = null - return result + return raw_command # No queued command, try to read a new one var line := _line_reader.read_text() @@ -61,11 +99,42 @@ func _pop() -> TrimsockCommand: var command := _line_parser.parse(line) if command.is_raw: + var data_size := _parse_data_size(command.text) + if data_size < 0: + _set_error(ERR_INVALID_DATA, "Invalid raw command size: \"%s\"!" % [command.text]) + return null + # Command is raw, we'll keep it in the queue until we read the binary # data for it _queued_raw = command + _queued_raw_size = data_size # Try getting it immediately, in case we already have the data in buffer return _pop() return command + +# Parse the data size of a raw command or return -1 if it's invalid +func _parse_data_size(text: String) -> int: + if not text.is_valid_int(): + return -1 + + var size := text.to_int() + + # The data and its terminating newline must both fit in the buffer + if size < 0 or size >= max_size: + return -1 + + return size + +func _dequeue_raw() -> void: + _queued_raw = null + _queued_raw_size = -1 + +func _clear_error() -> void: + last_error = OK + last_error_message = "" + +func _set_error(error: Error, message: String) -> void: + last_error = error + last_error_message = message diff --git a/trimsock.gd/tests/reactor.test.gd b/trimsock.gd/tests/reactor.test.gd index a58e468..21bad00 100644 --- a/trimsock.gd/tests/reactor.test.gd +++ b/trimsock.gd/tests/reactor.test.gd @@ -99,3 +99,21 @@ func suite(): expect(reactor.outbox[0].command.is_stream(), "Command was not a stream!") expect_not_empty(reactor.outbox[0].command.exchange_id, "Stream ID was empty!") ) + + test("should keep parsing after an unsatisfiable raw command", func(): + var errors := [] + reactor.on_ingest_error.connect(func(source, error, message): errors.append(error)) + + var commands := [] + reactor.on("command", func(cmd, xchg): commands.append(cmd)) + + # The blank line terminating the headers parses as a raw command with no size + reactor.ingest_text(some_source, "GET / HTTP/1.1\r\nHost: x\r\n\r\n") + reactor.poll() + + reactor.ingest_text(some_source, "command foo\n") + reactor.poll() + + expect_not_empty(errors, "No errors reported!") + expect_not_empty(commands, "No commands handled!") + ) diff --git a/trimsock.gd/tests/reader.test.gd b/trimsock.gd/tests/reader.test.gd index 98ce24e..a113cb2 100644 --- a/trimsock.gd/tests/reader.test.gd +++ b/trimsock.gd/tests/reader.test.gd @@ -19,3 +19,134 @@ func suite(): expect_true(command.is_raw) expect_equal(command.raw, "a\ncd".to_utf8_buffer()) ) + + define("raw data terminator", func(): + test("should read raw message split at its terminator", func(): + reader.ingest_text("\rcommand 4\n1234") + expect_null(reader.read(), "Command was read without its terminator!") + + reader.ingest_text("\n") + var command := reader.read() + + expect_not_null(command) + expect_equal(command.raw, "1234".to_utf8_buffer()) + ) + + test("should not emit a command for the terminator", func(): + reader.ingest_text("\rcommand 4\n1234\ncommand after\n") + + expect_equal(read_names(), ["command", "command"]) + ) + + test("should reject raw data with a malformed terminator", func(): + reader.ingest_text("\rcommand 4\n1234X\n") + + expect_null(reader.read(), "Command was read!") + expect_not_equal(reader.last_error, OK, "No error was reported!") + ) + + test("should keep parsing after a malformed terminator", func(): + # The malformed terminator is consumed in its place, so parsing + # resumes on the next line + reader.ingest_text("\rcommand 4\n1234Xcommand foo\n") + expect_null(reader.read(), "Command was read!") + + expect_equal(read_names(), ["command"]) + ) + ) + + define("unsatisfiable raw commands", func(): + check_invalid_raw("missing size", "\rcommand\n") + check_invalid_raw("non-numeric size", "\rcommand foo\n") + check_invalid_raw("blank line", "\r\n") + check_invalid_raw("negative size", "\rcommand -4\n") + check_invalid_raw("size over max_size", "\rcommand 100000\n") + check_invalid_raw("size just over max_size", "\rcommand 16385\n") + # The terminating newline wouldn't fit in the buffer + check_invalid_raw("size at max_size", "\rcommand 16384\n") + + test("should keep parsing after rejecting a raw command", func(): + reader.ingest_text("\r\n") + expect_null(reader.read(), "Command was read!") + expect_not_equal(reader.last_error, OK, "No error was reported!") + + reader.ingest_text("command foo\n") + expect_equal(read_names(), ["command"]) + ) + + test("should accept raw command sized up to max_size", func(): + reader.max_size = 16 + + # 15 bytes of data plus the terminating newline exactly fill the buffer + reader.ingest_text("\rcommand 15\n") + expect_null(reader.read(), "Command was read without data!") + + reader.ingest_text("012345678901234\n") + var command := reader.read() + + expect_not_null(command) + expect_equal(command.raw, "012345678901234".to_utf8_buffer()) + ) + ) + + define("buffer overflow", func(): + test("should reject data over max_size", func(): + reader.max_size = 8 + + expect_not_equal(reader.ingest_text("command foobar\n"), OK) + ) + + test("should resume parsing after discarding the buffer", func(): + reader.max_size = 16 + + reader.ingest_text("012345678901") + expect_null(reader.read(), "Command was read!") + + expect_not_equal(reader.ingest_text("01234"), OK, "No overflow!") + + reader.ingest_text("command\n") + expect_equal(read_names(), ["command"]) + ) + + test("should reset quote state after discarding the buffer", func(): + reader.max_size = 16 + + reader.ingest_text("\"") + expect_null(reader.read(), "Command was read!") + + expect_not_equal(reader.ingest_text("0123456789012345"), OK, "No overflow!") + + reader.ingest_text("command foo\n") + expect_equal(read_names(), ["command"]) + ) + + test("should drop queued raw command after discarding the buffer", func(): + reader.max_size = 16 + + reader.ingest_text("\rcommand 8\n") + expect_null(reader.read(), "Command was read without data!") + + expect_not_equal(reader.ingest_text("0".repeat(17)), OK, "No overflow!") + + reader.ingest_text("command foo\n") + expect_equal(read_names(), ["command"]) + ) + ) + +func check_invalid_raw(name: String, input: String) -> void: + test("should reject raw command with " + name, func(): + reader.ingest_text(input) + + expect_null(reader.read(), "Command was read!") + expect_not_equal(reader.last_error, OK, "No error was reported!") + ) + +# Read all the commands available and return their names +func read_names() -> Array: + var names := [] + while true: + var command := reader.read() + if not command: break + names.append(command.name) + + return names diff --git a/trimsock.js/.gitignore b/trimsock.js/.gitignore index e0473bd..8dc99a6 100644 --- a/trimsock.js/.gitignore +++ b/trimsock.js/.gitignore @@ -158,6 +158,7 @@ dist # Stores VSCode versions used for testing VSCode extensions +.vscode .vscode-test # yarn v2 diff --git a/trimsock.js/packages/trimsock-bun/lib/bun.socket.reactor.ts b/trimsock.js/packages/trimsock-bun/lib/bun.socket.reactor.ts index 6ba9138..0c8d00a 100644 --- a/trimsock.js/packages/trimsock-bun/lib/bun.socket.reactor.ts +++ b/trimsock.js/packages/trimsock-bun/lib/bun.socket.reactor.ts @@ -75,12 +75,14 @@ export class BunSocketReactor extends Reactor< }, close: (socket, error) => { baseHandlers.close?.call(baseHandlers.close, socket, error); + this.detach(socket); }, drain: (socket) => { baseHandlers.drain?.call(baseHandlers.drain, socket); }, error: (socket, error) => { baseHandlers.error?.call(baseHandlers.error, socket, error); + this.detach(socket); }, }, }; diff --git a/trimsock.js/packages/trimsock-js/lib/reader.ts b/trimsock.js/packages/trimsock-js/lib/reader.ts index a7eacda..3f1d42d 100644 --- a/trimsock.js/packages/trimsock-js/lib/reader.ts +++ b/trimsock.js/packages/trimsock-js/lib/reader.ts @@ -4,7 +4,11 @@ import { RequestResponseConvention, StreamConvention, } from "./conventions.js"; -import { BufferOverflowError, UnexpectedCharacterError } from "./errors.js"; +import { + BufferOverflowError, + ParserError, + UnexpectedCharacterError +} from "./errors.js"; /* * Converts the ingested data into either command lines that can be parsed, or @@ -18,10 +22,17 @@ class CommandReader { private isQuote = false; private isEscape = false; + reset() { + this.buffer = Buffer.of(); + this.at = 0; + this.isQuote = false; + this.isEscape = false; + } + ingest(data: Buffer) { const newSize = this.buffer.byteLength + data.byteLength; if (newSize > this.maxSize) { - this.buffer = Buffer.of(); + this.reset(); throw new BufferOverflowError( `Buffer overflow! New size ${newSize} exceeds ${this.maxSize}!`, @@ -58,11 +69,23 @@ class CommandReader { this.isEscape = false; this.isQuote = false; - if (this.buffer.length >= size) { + // The raw data is followed by a terminating newline, so both need to + // arrive before the command can be extracted + if (this.buffer.length > size) { const result = this.buffer.subarray(0, size); + + this.at = size; + const isTerminated = this.char === "\n"; + this.at = size + 1; this.flush(); + // Check for correct termination after flush to ensure reader remains in a valid state + if (!isTerminated) + throw new UnexpectedCharacterError( + `Expected newline after ${size} bytes of raw data!`, + ); + return result; } } @@ -222,6 +245,8 @@ export class TrimsockReader { * * If this size limit is exceeded, the buffer's contents are discarded and a * {@link BufferOverflowError} is thrown. + * + * Throws {@link ParserError} if command size is invalid. */ public maxSize = 16384; @@ -245,8 +270,14 @@ export class TrimsockReader { ingest(data: Buffer | string) { this.reader.maxSize = this.maxSize; - if (typeof data === "string") this.reader.ingest(Buffer.from(data, "utf8")); - else this.reader.ingest(data); + try { + if (typeof data === "string") + this.reader.ingest(Buffer.from(data, "utf8")); + else this.reader.ingest(data); + } catch (e) { + this.dequeueRaw(); + throw e; + } } /** @@ -284,7 +315,13 @@ export class TrimsockReader { private pop(): CommandSpec | undefined { if (this.queuedRawCommand !== undefined) { - const data = this.reader.readRaw(this.queuedRawSize); + let data: Buffer | undefined; + try { + data = this.reader.readRaw(this.queuedRawSize); + } catch (e) { + this.dequeueRaw(); + throw e; + } if (!data) return; const result: CommandSpec = { @@ -292,7 +329,7 @@ export class TrimsockReader { raw: data, }; - this.queuedRawCommand = undefined; + this.dequeueRaw(); return result; } @@ -301,11 +338,24 @@ export class TrimsockReader { const command = this.parser.parse(line); if (command.raw !== undefined) { + const size = Number.parseInt(command.text ?? ""); + + // The data and the terminating newline must both fit in the buffer + if (!Number.isInteger(size) || size < 0 || size >= this.maxSize) + throw new ParserError( + `Invalid raw command size: ${command.text}`, + ); + this.queuedRawCommand = command; - this.queuedRawSize = Number.parseInt(command.text ?? ""); + this.queuedRawSize = size; return this.read(); } return command; } + + private dequeueRaw() { + this.queuedRawCommand = undefined; + this.queuedRawSize = -1; + } } diff --git a/trimsock.js/packages/trimsock-js/spec/reactor/reactor.spec.ts b/trimsock.js/packages/trimsock-js/spec/reactor/reactor.spec.ts index 6e0948e..57c6277 100644 --- a/trimsock.js/packages/trimsock-js/spec/reactor/reactor.spec.ts +++ b/trimsock.js/packages/trimsock-js/spec/reactor/reactor.spec.ts @@ -24,6 +24,21 @@ describe("Reactor", () => { expect(reactor.outbox).toBeEmpty(); }); + test("should keep parsing after an unsatisfiable raw command", async () => { + const errors: unknown[] = []; + reactor.onIngestError((error) => errors.push(error)); + reactor.on("echo", (cmd, xchg) => { + xchg.replyOrSend(cmd); + }); + + // The blank line terminating the headers parses as a raw command with no size. + await reactor.ingest("GET / HTTP/1.1\r\nHost: x\r\n\r\n", "0"); + await reactor.ingest("echo foo\n", "0"); + + expect(errors).not.toBeEmpty(); + expect(reactor.outbox).not.toBeEmpty(); + }); + test("should not throw on unknown exchange", async () => { expect( async () => await reactor.ingest(".1234 foo\n", "0"), @@ -52,6 +67,20 @@ describe("Reactor", () => { }); }); + describe("detach()", () => { + test("should free the source's reader", async () => { + const handler = mock(); + reactor.on("command", handler); + + await reactor.ingest("comm", "0"); + reactor.detach("0"); + + await reactor.ingest("and foo\n", "0"); + + expect(handler.mock.calls).toBeEmpty(); + }); + }); + describe("knownCommands", () => { test("should return list", () => { reactor.on("foo", () => {}); diff --git a/trimsock.js/packages/trimsock-js/spec/reader.spec.ts b/trimsock.js/packages/trimsock-js/spec/reader.spec.ts index 27f00a4..1591636 100644 --- a/trimsock.js/packages/trimsock-js/spec/reader.spec.ts +++ b/trimsock.js/packages/trimsock-js/spec/reader.spec.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; import type { CommandSpec } from "@lib/command.js"; -import { BufferOverflowError } from "@lib/errors.js"; +import { + BufferOverflowError, + ParserError, + UnexpectedCharacterError, +} from "@lib/errors.js"; import { TrimsockReader } from "@lib/reader.js"; type Kase = [string, string[] | string, CommandSpec[]]; @@ -166,6 +170,18 @@ describe("TrimsockReader", () => { ["\rcommand 1", "0\n", "0123", "456789\n"], [{ name: "command", raw: Buffer.from("0123456789") }], ], + [ + "should parse raw command split at its terminator", + ["\rcommand 4\n", "1234", "\ncommand after\n"], + [ + { name: "command", raw: Buffer.from("1234") }, + { + name: "command", + text: "after", + chunks: [{ text: "after", isQuoted: false }], + }, + ], + ], [ "should parse regular and raw commands", ["command foo\n\rraw 4\n1234\ncommand bar\n"], @@ -185,13 +201,137 @@ describe("TrimsockReader", () => { ], ])); - test("should throw on buffer overflow", () => { - const reader = new TrimsockReader(); - reader.maxSize = 8; + describe("unsatisfiable raw commands", () => { + const kases: [string, string][] = [ + ["missing size", "\rcommand\n"], + ["non-numeric size", "\rcommand foo\n"], + ["blank line", "\r\n"], + ["negative size", "\rcommand -4\n"], + ["size over maxSize", "\rcommand 100000\n"], + ["size just over maxSize", "\rcommand 16385\n"], + // The terminating newline wouldn't fit in the buffer + ["size at maxSize", "\rcommand 16384\n"], + ]; + + for (const [name, input] of kases) + test(`should reject raw command with ${name}`, () => { + const reader = new TrimsockReader(); + reader.ingest(input); - expect(() => reader.ingest("command foobar\n")).toThrow( - BufferOverflowError, - ); + expect(() => [...reader.commands()]).toThrow(ParserError); + }); + + test("should keep parsing after rejecting a raw command", () => { + const reader = new TrimsockReader(); + + reader.ingest("\r\n"); + expect(() => [...reader.commands()]).toThrow(ParserError); + + reader.ingest("command foo\n"); + expect([...reader.commands()].map((it) => it.name)).toEqual(["command"]); + }); + + test("should accept raw command sized up to maxSize", () => { + const reader = new TrimsockReader(); + reader.maxSize = 16; + + // 15 bytes of data plus the terminating newline exactly fill the buffer + reader.ingest("\rcommand 15\n"); + expect([...reader.commands()]).toBeEmpty(); + + reader.ingest("012345678901234\n"); + expect([...reader.commands()]).toEqual([ + { name: "command", raw: Buffer.from("012345678901234") }, + ]); + }); + }); + + describe("raw data terminator", () => { + test("should not emit a command for the terminator", () => { + const reader = new TrimsockReader(); + + reader.ingest("\rcommand 4\n"); + expect([...reader.commands()]).toBeEmpty(); + + // The data arrives without its terminator + reader.ingest("1234"); + expect([...reader.commands()]).toBeEmpty(); + + reader.ingest("\n"); + expect([...reader.commands()]).toEqual([ + { name: "command", raw: Buffer.from("1234") }, + ]); + }); + + test("should reject raw data with a malformed terminator", () => { + const reader = new TrimsockReader(); + reader.ingest("\rcommand 4\n1234X\n"); + + expect(() => [...reader.commands()]).toThrow(UnexpectedCharacterError); + }); + + test("should keep parsing after a malformed terminator", () => { + const reader = new TrimsockReader(); + + // The malformed terminator is consumed in its place, so parsing resumes + // on the next line + reader.ingest("\rcommand 4\n1234Xcommand foo\n"); + expect(() => [...reader.commands()]).toThrow(UnexpectedCharacterError); + + expect([...reader.commands()].map((it) => it.name)).toEqual(["command"]); + }); + }); + + describe("buffer overflow", () => { + test("should throw on buffer overflow", () => { + const reader = new TrimsockReader(); + reader.maxSize = 8; + + expect(() => reader.ingest("command foobar\n")).toThrow( + BufferOverflowError, + ); + }); + + test("should resume parsing after discarding the buffer", () => { + const reader = new TrimsockReader(); + reader.maxSize = 16; + + reader.ingest("012345678901"); + expect([...reader.commands()]).toBeEmpty(); + + expect(() => reader.ingest("01234")).toThrow(BufferOverflowError); + + reader.ingest("command\n"); + expect([...reader.commands()].map((it) => it.name)).toEqual(["command"]); + }); + + test("should reset quote state after discarding the buffer", () => { + const reader = new TrimsockReader(); + reader.maxSize = 16; + + reader.ingest('"'); + expect([...reader.commands()]).toBeEmpty(); + + expect(() => reader.ingest("0123456789012345")).toThrow( + BufferOverflowError, + ); + + reader.ingest("command foo\n"); + expect([...reader.commands()].map((it) => it.name)).toEqual(["command"]); + }); + + test("should drop queued raw command after discarding the buffer", () => { + const reader = new TrimsockReader(); + reader.maxSize = 16; + + reader.ingest("\rcommand 8\n"); + expect([...reader.commands()]).toBeEmpty(); + + expect(() => reader.ingest("0".repeat(17))).toThrow(BufferOverflowError); + + reader.ingest("command foo\n"); + expect([...reader.commands()].map((it) => it.name)).toEqual(["command"]); + }); }); }); diff --git a/trimsock.js/packages/trimsock-node/lib/node.socket.reactor.ts b/trimsock.js/packages/trimsock-node/lib/node.socket.reactor.ts index a5d627c..a7c4eaf 100644 --- a/trimsock.js/packages/trimsock-node/lib/node.socket.reactor.ts +++ b/trimsock.js/packages/trimsock-node/lib/node.socket.reactor.ts @@ -32,6 +32,7 @@ export class NodeSocketReactor extends Reactor { .createServer(options, connectionListener) .on("connection", (socket: net.Socket) => { socket.on("data", (data: Buffer) => this.ingest(data, socket)); + socket.on("close", () => this.detach(socket)); }); } @@ -52,6 +53,7 @@ export class NodeSocketReactor extends Reactor { ): net.Socket { const socket = net.createConnection(options, connectionListener); socket.on("data", (data: Buffer) => this.ingest(data, socket)); + socket.on("close", () => this.detach(socket)); return socket; }