diff --git a/README.md b/README.md index ff49770..8d2fe26 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ The project is currently an early but usable Telnet client. Its interface is bui - Plain Telnet and certificate-verified TLS connections - UTF-8, Windows-1252, and ISO-8859-1 character encodings - Streaming Telnet negotiation with ECHO, Suppress Go Ahead, and Terminal Type support +- MCCP2 compressed-stream negotiation and bounded streaming decompression - Streaming ANSI control-sequence filtering - Accessible scrollback and command entry - Automatic reading of incoming lines and prompts @@ -51,12 +52,11 @@ Automated regression coverage now exercises the streaming Telnet parser and text Planned work, roughly in order: -1. Add MCCP2 compressed-stream support. -2. Add GMCP negotiation, bounded JSON parsing, typed package events, and an accessible inspector. -3. Add opt-in MSP sound and music with safe URL handling, limits, caching, and independent playback controls. -4. Add aliases, triggers, timers, optional transcripts, and configurable command shortcuts. -5. Add multiple simultaneous sessions with independent output and speech queues. -6. Add localization catalogs and package/signing automation for releases. +1. Add GMCP negotiation, bounded JSON parsing, typed package events, and an accessible inspector. +2. Add opt-in MSP sound and music with safe URL handling, limits, caching, and independent playback controls. +3. Add aliases, triggers, timers, optional transcripts, and configurable command shortcuts. +4. Add multiple simultaneous sessions with independent output and speech queues. +5. Add localization catalogs and package/signing automation for releases. ## Running the tests diff --git a/__app.rb b/__app.rb index 6569b6d..4ec6fd6 100644 --- a/__app.rb +++ b/__app.rb @@ -3,7 +3,7 @@ "id": "51a92051-84e0-415f-84e3-98f08a320a15", "name": "Meow", "version": "0.1.0", - "build_id": 2, + "build_id": 3, "EltenAPIVersion": "3.0.3", "author": "Talon", "main_language": "en", diff --git a/lib/meow/session.rb b/lib/meow/session.rb index 98c6e59..d3fbe4e 100644 --- a/lib/meow/session.rb +++ b/lib/meow/session.rb @@ -27,6 +27,7 @@ module MeowClient def run @form.wait ensure + @pipeline.close @transport.close @program.release(@transport, :close => false) end @@ -83,6 +84,9 @@ module MeowClient receive_record(record) if record != nil end dispatch_speech + rescue Telnet::CompressionError + add_system_line(_("The compressed data stream could not be processed safely. The connection was closed.")) + close_form rescue Exception => error Log.error("Meow session update failed: #{error.class}: #{error.message}") if defined?(Log) add_system_line(_("Session update error: %{message}") % {:message => error.message}) diff --git a/lib/meow/telnet.rb b/lib/meow/telnet.rb index 4d979e4..39c3d7f 100644 --- a/lib/meow/telnet.rb +++ b/lib/meow/telnet.rb @@ -1,3 +1,5 @@ +require "zlib" + module MeowClient module Telnet IAC = 255 @@ -11,10 +13,13 @@ module MeowClient ECHO = 1 SUPPRESS_GO_AHEAD = 3 TERMINAL_TYPE = 24 + COMPRESS2 = 86 TTYPE_IS = 0 TTYPE_SEND = 1 + MAX_INFLATED_BYTES_PER_FEED = 1_048_576 Result = Struct.new(:data, :replies, :events, keyword_init: true) + CompressionError = Class.new(StandardError) class Parser attr_reader :remote_echo @@ -27,56 +32,101 @@ module MeowClient @remote_echo = false @remote_options = {} @local_options = {} + @inflater = nil + @compression_finished = false end def feed(bytes) output = +"".b replies = [] events = [] - bytes.to_s.b.each_byte do |byte| - case @state - when :data - if byte == IAC - @state = :iac - else - output << byte - end - when :iac - handle_iac(byte, output) - when :negotiation - reply, event = negotiate(@negotiation_command, byte) - replies << reply if reply != nil - events << event if event != nil - @state = :data - when :sb_option - @subnegotiation_option = byte - @subnegotiation.clear - @state = :sb - when :sb - if byte == IAC - @state = :sb_iac - else - @subnegotiation << byte - end - when :sb_iac - if byte == IAC - @subnegotiation << IAC - @state = :sb - elsif byte == SE - reply, event = finish_subnegotiation - replies << reply if reply != nil - events << event if event != nil - @state = :data - else - @state = :data - end + input = bytes.to_s.b + if @compression_finished && input != "" + raise CompressionError, "Data followed the end of the MCCP2 stream." + end + + index = 0 + while index < input.bytesize + if @inflater != nil + inflate(input.byteslice(index..-1), output, replies, events) + index = input.bytesize + else + consume_byte(input.getbyte(index), output, replies, events) + index += 1 end end Result.new(:data => output, :replies => replies, :events => events) + rescue Zlib::Error => error + raise CompressionError, "Invalid MCCP2 stream: #{error.message}" + end + + def close + inflater = @inflater + @inflater = nil + inflater.close if inflater != nil && !inflater.closed? + nil + rescue Zlib::Error + nil end private + def consume_byte(byte, output, replies, events) + case @state + when :data + if byte == IAC + @state = :iac + else + output << byte + end + when :iac + handle_iac(byte, output) + when :negotiation + reply, event = negotiate(@negotiation_command, byte) + replies << reply if reply != nil + events << event if event != nil + @state = :data + when :sb_option + @subnegotiation_option = byte + @subnegotiation.clear + @state = :sb + when :sb + if byte == IAC + @state = :sb_iac + else + @subnegotiation << byte + end + when :sb_iac + if byte == IAC + @subnegotiation << IAC + @state = :sb + elsif byte == SE + reply, event = finish_subnegotiation + replies << reply if reply != nil + events << event if event != nil + @state = :data + else + @state = :data + end + end + end + + def inflate(bytes, output, replies, events) + inflated_bytes = 0 + consume = proc do |chunk| + inflated_bytes += chunk.bytesize + if inflated_bytes > MAX_INFLATED_BYTES_PER_FEED + raise CompressionError, "MCCP2 output exceeded the safety limit." + end + chunk.each_byte { |byte| consume_byte(byte, output, replies, events) } + end + + @inflater.inflate(bytes) { |chunk| consume.call(chunk) } + pending = @inflater.flush_next_out + consume.call(pending) if pending != "" + @compression_finished = true if @inflater.finished? + end + def handle_iac(byte, output) case byte when IAC @@ -99,7 +149,7 @@ module MeowClient def negotiate(command, option) case command when WILL - enabled = [ECHO, SUPPRESS_GO_AHEAD].include?(option) + enabled = [ECHO, SUPPRESS_GO_AHEAD, COMPRESS2].include?(option) changed = @remote_options[option] != enabled @remote_options[option] = enabled return [nil, nil] unless changed @@ -131,6 +181,13 @@ module MeowClient end def finish_subnegotiation + if @subnegotiation_option == COMPRESS2 && + @remote_options[COMPRESS2] == true && + @subnegotiation.empty? + raise CompressionError, "MCCP2 compression was started more than once." if @inflater != nil + @inflater = Zlib::Inflate.new + return [nil, [:compression, true]] + end if @subnegotiation_option == TERMINAL_TYPE && @local_options[TERMINAL_TYPE] == true && @subnegotiation.getbyte(0) == TTYPE_SEND diff --git a/lib/meow/text_pipeline.rb b/lib/meow/text_pipeline.rb index 19314de..f45aafe 100644 --- a/lib/meow/text_pipeline.rb +++ b/lib/meow/text_pipeline.rb @@ -94,6 +94,10 @@ module MeowClient prompt == nil ? [] : [prompt] end + def close + @telnet.close + end + private def decode(bytes) diff --git a/test/telnet_test.rb b/test/telnet_test.rb index 8acd986..821d5e6 100644 --- a/test/telnet_test.rb +++ b/test/telnet_test.rb @@ -1,5 +1,7 @@ # encoding: UTF-8 +require "zlib" + require_relative "test_helper" require_relative "../lib/meow/telnet" @@ -94,4 +96,104 @@ module MeowTelnetTest MeowTest.assert_empty(result.replies) MeowTest.assert_empty(result.events) end + + MeowTest.test("Telnet negotiates MCCP2 once") do + parser = Telnet::Parser.new + packet = bytes(Telnet::IAC, Telnet::WILL, Telnet::COMPRESS2) + first = parser.feed(packet) + duplicate = parser.feed(packet) + + MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::DO, Telnet::COMPRESS2)], first.replies) + MeowTest.assert_empty(duplicate.replies) + end + + MeowTest.test("Telnet starts MCCP2 only after negotiation") do + marker = bytes(Telnet::IAC, Telnet::SB, Telnet::COMPRESS2, Telnet::IAC, Telnet::SE) + parser = Telnet::Parser.new + before = parser.feed(marker) + parser.feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::COMPRESS2)) + after = parser.feed(marker + Zlib::Deflate.deflate("compressed text".b)) + + MeowTest.assert_equal([[:subnegotiation, Telnet::COMPRESS2]], before.events) + MeowTest.assert_equal("compressed text".b, after.data) + MeowTest.assert_equal([[:compression, true]], after.events) + ensure + parser.close if parser != nil + end + + MeowTest.test("MCCP2 survives every activation and compressed input boundary") do + negotiation = bytes(Telnet::IAC, Telnet::WILL, Telnet::COMPRESS2) + marker = bytes(Telnet::IAC, Telnet::SB, Telnet::COMPRESS2, Telnet::IAC, Telnet::SE) + payload = "room description\r\n".b + packet = negotiation + marker + Zlib::Deflate.deflate(payload) + whole_parser = Telnet::Parser.new + split_parser = Telnet::Parser.new + whole = collect(whole_parser, [packet]) + split = collect(split_parser, packet.bytes.map { |byte| bytes(byte) }) + + MeowTest.assert_equal(whole, split) + MeowTest.assert_equal(payload, split[:data]) + MeowTest.assert_equal([[:compression, true]], split[:events]) + ensure + whole_parser.close if whole_parser != nil + split_parser.close if split_parser != nil + end + + MeowTest.test("MCCP2 inflated bytes continue through Telnet parsing") do + parser = Telnet::Parser.new + parser.feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::COMPRESS2)) + marker = bytes(Telnet::IAC, Telnet::SB, Telnet::COMPRESS2, Telnet::IAC, Telnet::SE) + payload = "hello".b + bytes(Telnet::IAC, Telnet::WILL, Telnet::ECHO) + " world".b + result = parser.feed(marker + Zlib::Deflate.deflate(payload)) + + MeowTest.assert_equal("hello world".b, result.data) + MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::DO, Telnet::ECHO)], result.replies) + MeowTest.assert_equal([[:compression, true], [:remote_echo, true]], result.events) + ensure + parser.close if parser != nil + end + + + + MeowTest.test("MCCP2 keeps one compressed stream across input chunks") do + parser = Telnet::Parser.new + deflater = Zlib::Deflate.new + parser.feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::COMPRESS2)) + marker = bytes(Telnet::IAC, Telnet::SB, Telnet::COMPRESS2, Telnet::IAC, Telnet::SE) + first = deflater.deflate("first ".b, Zlib::SYNC_FLUSH) + MeowTest.assert_equal("first ".b, parser.feed(marker + first).data) + + second = deflater.deflate("second".b, Zlib::SYNC_FLUSH) + MeowTest.assert_equal("second".b, parser.feed(second).data) + ensure + parser.close if parser != nil + deflater.close if deflater != nil && !deflater.closed? + end + + MeowTest.test("MCCP2 rejects malformed streams and data after stream end") do + marker = bytes(Telnet::IAC, Telnet::SB, Telnet::COMPRESS2, Telnet::IAC, Telnet::SE) + malformed = Telnet::Parser.new + malformed.feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::COMPRESS2)) + MeowTest.assert_raises(Telnet::CompressionError) { malformed.feed(marker + "not zlib".b) } + + finished = Telnet::Parser.new + finished.feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::COMPRESS2)) + finished.feed(marker + Zlib::Deflate.deflate("done".b)) + MeowTest.assert_raises(Telnet::CompressionError) { finished.feed("extra".b) } + ensure + malformed.close if malformed != nil + finished.close if finished != nil + end + + MeowTest.test("MCCP2 limits inflated output per feed") do + parser = Telnet::Parser.new + parser.feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::COMPRESS2)) + marker = bytes(Telnet::IAC, Telnet::SB, Telnet::COMPRESS2, Telnet::IAC, Telnet::SE) + oversized = "a".b * (Telnet::MAX_INFLATED_BYTES_PER_FEED + 1) + MeowTest.assert_raises(Telnet::CompressionError) do + parser.feed(marker + Zlib::Deflate.deflate(oversized)) + end + ensure + parser.close if parser != nil + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 67c183f..a404833 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -29,6 +29,16 @@ module MeowTest assert(value.empty?, message || "Expected #{value.inspect} to be empty") end + def assert_raises(error_class) + yield + rescue error_class => error + return error + rescue StandardError => error + raise Failure, "Expected #{error_class}, got #{error.class}: #{error.message}" + else + raise Failure, "Expected #{error_class} to be raised" + end + def run! failures = [] @tests.each do |name, test| diff --git a/test/text_pipeline_test.rb b/test/text_pipeline_test.rb index 1805055..0a5d8dc 100644 --- a/test/text_pipeline_test.rb +++ b/test/text_pipeline_test.rb @@ -1,5 +1,7 @@ # encoding: UTF-8 +require "zlib" + require_relative "test_helper" require_relative "../lib/meow/telnet" require_relative "../lib/meow/text_pipeline" @@ -74,4 +76,18 @@ module MeowTextPipelineTest MeowTest.assert_equal([[:remote_echo, true]], result.events) MeowTest.assert(pipeline.remote_echo?) end + + MeowTest.test("Text pipeline processes MCCP2 output normally") do + pipeline = MeowClient::TextPipeline.new("UTF-8") + negotiation = bytes(Telnet::IAC, Telnet::WILL, Telnet::COMPRESS2) + marker = bytes(Telnet::IAC, Telnet::SB, Telnet::COMPRESS2, Telnet::IAC, Telnet::SE) + payload = "red\e[31m text\e[0m\r\n".b + result = pipeline.feed(negotiation + marker + Zlib::Deflate.deflate(payload)) + + MeowTest.assert_equal(["red text"], result.records) + MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::DO, Telnet::COMPRESS2)], result.replies) + MeowTest.assert_equal([[:compression, true]], result.events) + ensure + pipeline.close if pipeline != nil + end end