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..977727e 100644 --- a/trimsock.gd/addons/trimsock.gd/line_reader.gd +++ b/trimsock.gd/addons/trimsock.gd/line_reader.gd @@ -7,10 +7,17 @@ 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 +37,27 @@ 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) -> TrimsockResult.Data: 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 TrimsockResult.Data.of_error(ERR_PARSE_ERROR, + "Expected newline after %d bytes of raw data!" % [size]) + + return TrimsockResult.Data.of_value(result) func chr() -> String: return String.chr(buffer[at]) diff --git a/trimsock.gd/addons/trimsock.gd/plugin.cfg b/trimsock.gd/addons/trimsock.gd/plugin.cfg index a768d22..cdd8c22 100644 --- a/trimsock.gd/addons/trimsock.gd/plugin.cfg +++ b/trimsock.gd/addons/trimsock.gd/plugin.cfg @@ -3,5 +3,5 @@ name="trimsock.gd" description="Godot implementation of the trimsock protocol" author="Tamás Gálffy" -version="0.13.2" +version="0.13.3" script="trimsock.gd" diff --git a/trimsock.gd/addons/trimsock.gd/reactor.gd b/trimsock.gd/addons/trimsock.gd/reactor.gd index b80bc29..b46a363 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 @@ -46,8 +48,12 @@ func poll() -> void: for source in _sources: var reader := _readers[source] as TrimsockReader while true: - var command := reader.read() + var result := reader.read() + var command := result.value() if not command: + if not result.is_success(): + var error := result.error() + on_ingest_error.emit(source, error.code, error.message) break _handle(command, source) @@ -155,7 +161,13 @@ 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 result := reader.ingest_bytes(data) + if not result.is_success(): + var error := result.error() + on_ingest_error.emit(source, error.code, error.message) + return error.code + + return OK 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..e38be4d 100644 --- a/trimsock.gd/addons/trimsock.gd/reader.gd +++ b/trimsock.gd/addons/trimsock.gd/reader.gd @@ -9,63 +9,120 @@ 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 the returned [TrimsockResult]. 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 _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()) +## Returns a successful result, or an error result with +## [constant ERR_OUT_OF_MEMORY] if the internal buffer can't store the data. +func ingest_text(text: String) -> TrimsockResult: + 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) +## Returns a successful result, or an error result with +## [constant ERR_OUT_OF_MEMORY] if the internal buffer can't store the data. +func ingest_bytes(bytes: PackedByteArray) -> TrimsockResult: + var error := _line_reader.ingest(bytes) + if error != OK: + _dequeue_raw() + return TrimsockResult.failure(error, + "Buffer overflow! Can't ingest %d bytes without exceeding %d!" \ + % [bytes.size(), max_size]) + + return TrimsockResult.success() ## 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. -func read() -> TrimsockCommand: - var command := _pop() +## Returns a result holding the parsed command, or a successful result without +## a command if none is available yet. Malformed data is rejected by returning +## an error result. +func read() -> TrimsockResult.Command: + var result := _pop() + var command := result.value() if command: _TrimsockConventions.apply(command) - return command + return result -func _pop() -> TrimsockCommand: +func _pop() -> TrimsockResult.Command: # 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): - return null + if not _line_reader.has_data(_queued_raw_size): + return TrimsockResult.Command.of_success() + + var raw_command := _queued_raw + var read_result := _line_reader.read_data(_queued_raw_size) + + _dequeue_raw() + + if not read_result.is_success(): + var error := read_result.error() + return TrimsockResult.Command.of_error(error.code, error.message) - _queued_raw.raw = _line_reader.read_data(data_size) - _queued_raw.text = "" - _queued_raw.chunks.clear() + raw_command.raw = read_result.value() + raw_command.text = "" + raw_command.chunks.clear() - var result := _queued_raw - _queued_raw = null - return result + return TrimsockResult.Command.of_value(raw_command) # No queued command, try to read a new one var line := _line_reader.read_text() if not line: - return null + return TrimsockResult.Command.of_success() var command := _line_parser.parse(line) if command.is_raw: + var data_size := _parse_data_size(command.text) + if data_size < 0: + return TrimsockResult.Command.of_error(ERR_INVALID_DATA, + "Invalid raw command size: \"%s\"!" % [command.text]) + # Command is raw, we'll keep it in the queue until we read the binary # data for it - _queued_raw = command + _queue_raw(command, data_size) # Try getting it immediately, in case we already have the data in buffer return _pop() - return command + return TrimsockResult.Command.of_value(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 _queue_raw(command: TrimsockCommand, data_size: int) -> void: + _queued_raw = command + _queued_raw_size = data_size + +func _dequeue_raw() -> void: + _queued_raw = null + _queued_raw_size = -1 diff --git a/trimsock.gd/addons/trimsock.gd/result.gd b/trimsock.gd/addons/trimsock.gd/result.gd new file mode 100644 index 0000000..6045519 --- /dev/null +++ b/trimsock.gd/addons/trimsock.gd/result.gd @@ -0,0 +1,133 @@ +extends RefCounted +class_name TrimsockResult + +## Represents the result of a trimsock operation +## +## If the call was successful, this instance will store the resulting +## value, and [method is_success] will return [code]true[/code]. This class has +## multiple specializations, each implementing a [code]value()[/code] method +## that returns a type-safe success result. +## [br][br] +## If the call failed, [method is_success] will return [code]false[/code]. The +## actual error is returned by [method error]. + +## Represents an error encountered while ingesting or parsing trimsock data +class ErrorData: + ## The error code + ## [br][br] + ## This indicates the error type. Can be checked programmatically to + ## intelligently try and recover from an error. + var code: Error + + ## The error message + ## [br][br] + ## This gives a further description of the error itself. Should not be + ## relied on, error messages may change between releases without notice. + ## However, they can be useful to convey the issue to the user. + var message: String + + func _init(p_code: Error, p_message: String): + code = p_code + message = p_message + + func _to_string() -> String: + return "%s: %s" % [error_string(code), message] + + +## Stores a [TrimsockCommand] on success +## +## See [TrimsockResult] for details. +class Command extends TrimsockResult: + ## Construct a successful result object with the given [param value] + static func of_value(value: TrimsockCommand) -> Command: + var result := Command.new() + result._is_success = true + result._value = value + return result + + ## Construct a successful result object without any command + ## [br][br] + ## Used when no command could be extracted yet, without any error. + static func of_success() -> Command: + var result := Command.new() + result._is_success = true + return result + + ## Construct an error result + static func of_error(error: Error, message: String) -> Command: + var result := Command.new() + result._is_success = false + result._error = ErrorData.new(error, message) + return result + + ## Get the resulting command + ## [br][br] + ## Returns [code]null[/code] if the operation failed, or if it succeeded + ## without extracting a command. + func value() -> TrimsockCommand: + if _is_success: + return _value as TrimsockCommand + else: + return null + +## Stores binary data on success +## +## See [TrimsockResult] for details. +class Data extends TrimsockResult: + ## Construct a successful result object with the given [param value] + static func of_value(value: PackedByteArray) -> Data: + var result := Data.new() + result._is_success = true + result._value = value + return result + + ## Construct an error result + static func of_error(error: Error, message: String) -> Data: + var result := Data.new() + result._is_success = false + result._error = ErrorData.new(error, message) + return result + + ## Get the resulting data + ## [br][br] + ## Returns an empty array if the operation failed. + func value() -> PackedByteArray: + if _is_success: + return _value as PackedByteArray + else: + return PackedByteArray() + +var _is_success: bool +var _value: Variant +var _error: ErrorData + +## Construct an error result +static func failure(error: Error, message: String) -> TrimsockResult: + var result := TrimsockResult.new() + result._is_success = false + result._error = ErrorData.new(error, message) + return result + +## Construct a successful result without any value +static func success() -> TrimsockResult: + var result := TrimsockResult.new() + result._is_success = true + return result + + +## Return true if the operation was successful +func is_success() -> bool: + return _is_success + +## Return the error, or [code]null[/code] if the operation was successful +func error() -> ErrorData: + if _is_success: + return null + else: + return _error + +func _to_string() -> String: + if _is_success: + return str(_value) + else: + return str(_error) diff --git a/trimsock.gd/addons/trimsock.gd/result.gd.uid b/trimsock.gd/addons/trimsock.gd/result.gd.uid new file mode 100644 index 0000000..d936476 --- /dev/null +++ b/trimsock.gd/addons/trimsock.gd/result.gd.uid @@ -0,0 +1 @@ +uid://b8q6e1o6dw1eh diff --git a/trimsock.gd/tests/conventions.test.gd b/trimsock.gd/tests/conventions.test.gd index 7466954..4754278 100644 --- a/trimsock.gd/tests/conventions.test.gd +++ b/trimsock.gd/tests/conventions.test.gd @@ -39,7 +39,7 @@ func suite(): test("params should passthrough raw", func(): reader.ingest_text("\rcmd 4\n1234\n") - var command := reader.read() + var command := reader.read().value() expect_not_null(command) expect(command.is_raw, "Command must be raw!") expect(not command.text, "Command should have no text!") @@ -53,7 +53,7 @@ func check_type(name: String, input: String, expected_type: TrimsockCommand.Type test(name, func(): reader.ingest_text(input) - var command := reader.read() + var command := reader.read().value() expect_not_null(command, "Command was null!") expect_equal( TrimsockCommand.type_string(command.type), @@ -67,7 +67,7 @@ func check_params(name: String, input: String, expected_params: Array, expected_ test(name, func(): reader.ingest_text(input) - var command := reader.read() + var command := reader.read().value() expect_not_null(command) expect_equal(command.params, expected_params, "Params did not match!") expect_equal(command.kv_pairs.map(func(it): return [it.key, it.value]), expected_pairs, "KV-pairs did not match!") diff --git a/trimsock.gd/tests/reactor.test.gd b/trimsock.gd/tests/reactor.test.gd index a58e468..51b3f98 100644 --- a/trimsock.gd/tests/reactor.test.gd +++ b/trimsock.gd/tests/reactor.test.gd @@ -99,3 +99,20 @@ 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(): + capture_signal(reactor.on_ingest_error, 3) + + 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(get_signal_emissions(reactor.on_ingest_error), "No errors reported!") + expect_not_empty(commands, "No commands handled!") + ) diff --git a/trimsock.gd/tests/reader.perf.gd b/trimsock.gd/tests/reader.perf.gd index 26b5079..5f51c50 100644 --- a/trimsock.gd/tests/reader.perf.gd +++ b/trimsock.gd/tests/reader.perf.gd @@ -18,7 +18,7 @@ func suite(): test("Parsing", func(): benchmark("Predefined commands", func(__): reader.ingest_text(COMMANDS[idx]) - while reader.read() != null: + while reader.read().value() != null: pass idx = (idx + 1) % COMMANDS.size() )\ diff --git a/trimsock.gd/tests/reader.test.gd b/trimsock.gd/tests/reader.test.gd index 98ce24e..614e2d6 100644 --- a/trimsock.gd/tests/reader.test.gd +++ b/trimsock.gd/tests/reader.test.gd @@ -13,9 +13,151 @@ func suite(): test("should read raw message", func(): ok() reader.ingest_text("\rcommand 4\na\ncd\n") - var command := reader.read() + var command := reader.read().value() expect_not_null(command) 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().value(), "Command was read without its terminator!") + + reader.ingest_text("\n") + var command := reader.read().value() + + 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 incorrect size", func(): + reader.ingest_text("\rinvalid-command 4\n1234X\n") + + var result := reader.read() + expect_null(result.value(), "Command was read!") + expect_error(result.error(), ERR_PARSE_ERROR) + ) + + test("should keep parsing after a malformed terminator", func(): + # The malformed terminator "X" is consumed in its place, so parsing + # resumes on the next line + reader.ingest_text("\rinvalid-command 4\n1234Xvalid-command foo\n") + expect_null(reader.read().value(), "Command was read!") + + expect_equal(read_names(), ["valid-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") + var result := reader.read() + expect_null(result.value(), "Command was read!") + expect_error(result.error(), ERR_INVALID_DATA) + + 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().value(), "Command was read without data!") + + reader.ingest_text("012345678901234\n") + var command := reader.read().value() + + 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_error(reader.ingest_text("command foobar\n").error(), ERR_OUT_OF_MEMORY) + ) + + test("should resume parsing after discarding the buffer", func(): + reader.max_size = 16 + + reader.ingest_text("012345678901") + expect_null(reader.read().value(), "Command was read!") + + expect_error(reader.ingest_text("01234").error(), ERR_OUT_OF_MEMORY) + + 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().value(), "Command was read!") + + expect_error(reader.ingest_text("0123456789012345").error(), ERR_OUT_OF_MEMORY) + + 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().value(), "Command was read without data!") + + expect_error(reader.ingest_text("0".repeat(17)).error(), ERR_OUT_OF_MEMORY) + + 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) + + var result := reader.read() + expect_null(result.value(), "Command was read!") + expect_error(result.error(), ERR_INVALID_DATA) + ) + +# Assert that the reported error has the specified error code +func expect_error(error: TrimsockResult.ErrorData, code: Error) -> void: + expect_not_null(error, "No error was reported!") + + if error: + expect_equal(error.code, code, "Reported %s instead of %s!" \ + % [error_string(error.code), error_string(code)]) + +# Read all the commands available and return their names +func read_names() -> Array: + var names := [] + while true: + var command := reader.read().value() + if not command: break + names.append(command.name) + + return names diff --git a/trimsock.js/.gitattributes b/trimsock.js/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/trimsock.js/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf 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/bun.lockb b/trimsock.js/bun.lockb old mode 100644 new mode 100755 index 9fcbd89..a850a29 Binary files a/trimsock.js/bun.lockb and b/trimsock.js/bun.lockb differ diff --git a/trimsock.js/package.json b/trimsock.js/package.json index 010dda2..0b5d32b 100644 --- a/trimsock.js/package.json +++ b/trimsock.js/package.json @@ -1,7 +1,7 @@ { "name": "trimsock", "private": true, - "version": "0.19.0", + "version": "0.19.1", "type": "module", "scripts": { "docs": "typedoc", @@ -10,12 +10,9 @@ "build": "bun --filter '*' build", "perf": "bun --filter '*' perf" }, - "workspaces": [ - "packages/*", - "examples/*" - ], + "workspaces": ["packages/*", "examples/*"], "devDependencies": { "typedoc": "^0.28.8", "typedoc-plugin-markdown": "^4.8.0" } -} \ No newline at end of file +} 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..26f4fe3 100644 --- a/trimsock.js/packages/trimsock-bun/lib/bun.socket.reactor.ts +++ b/trimsock.js/packages/trimsock-bun/lib/bun.socket.reactor.ts @@ -66,6 +66,7 @@ export class BunSocketReactor extends Reactor< return { socket: { + ...baseHandlers, data: (socket, data) => { baseHandlers.data?.call(baseHandlers.data, socket, data); this.ingest(data, socket); @@ -75,6 +76,7 @@ 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); diff --git a/trimsock.js/packages/trimsock-bun/package.json b/trimsock.js/packages/trimsock-bun/package.json index 099f1de..9506067 100644 --- a/trimsock.js/packages/trimsock-bun/package.json +++ b/trimsock.js/packages/trimsock-bun/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/trimsock-bun", - "version": "0.19.0", + "version": "0.19.1", "module": "dist/index.js", "type": "module", "author": "Tamás Gálffy", @@ -16,7 +16,7 @@ }, "devDependencies": { "@biomejs/biome": "1.9.4", - "@types/bun": "latest" + "@types/bun": "1.3.0" }, "peerDependencies": { "typescript": "^5.0.0" @@ -24,6 +24,6 @@ "files": ["./dist/"], "exports": "./dist/index.js", "engines": { - "bun": "1.1.34" + "bun": "1.3.0" } } diff --git a/trimsock.js/packages/trimsock-js/lib/reader.ts b/trimsock.js/packages/trimsock-js/lib/reader.ts index a7eacda..f6688ee 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,22 @@ 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/package.json b/trimsock.js/packages/trimsock-js/package.json index a6ff895..4db1a99 100644 --- a/trimsock.js/packages/trimsock-js/package.json +++ b/trimsock.js/packages/trimsock-js/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/trimsock-js", - "version": "0.19.0", + "version": "0.19.1", "module": "dist/index.js", "type": "module", "author": "Tamás Gálffy", 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..5447258 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,139 @@ 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 incorrect size", () => { + 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 (X) is consumed in its place, so parsing resumes + // on the next line + reader.ingest("\rinvalid-command 4\n1234Xvalid-command foo\n"); + expect(() => [...reader.commands()]).toThrow(UnexpectedCharacterError); + + expect([...reader.commands()].map((it) => it.name)).toEqual([ + "valid-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..25a8e76 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,8 @@ 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)); + socket.on("error", (err: Error) => console.error(err)); }); } @@ -52,6 +54,8 @@ 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)); + socket.on("error", (err: Error) => console.error(err)); return socket; } diff --git a/trimsock.js/packages/trimsock-node/package.json b/trimsock.js/packages/trimsock-node/package.json index 0ac06ae..e61c9ce 100644 --- a/trimsock.js/packages/trimsock-node/package.json +++ b/trimsock.js/packages/trimsock-node/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/trimsock-node", - "version": "0.19.0", + "version": "0.19.1", "module": "./dist/index.js", "type": "module", "author": "Tamás Gálffy",