Add MCCP2 compressed stream support

This commit is contained in:
2026-09-17 16:18:16 +02:00
parent da967f4d56
commit 158ee2568c
8 changed files with 238 additions and 45 deletions
+6 -6
View File
@@ -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 - Plain Telnet and certificate-verified TLS connections
- UTF-8, Windows-1252, and ISO-8859-1 character encodings - UTF-8, Windows-1252, and ISO-8859-1 character encodings
- Streaming Telnet negotiation with ECHO, Suppress Go Ahead, and Terminal Type support - 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 - Streaming ANSI control-sequence filtering
- Accessible scrollback and command entry - Accessible scrollback and command entry
- Automatic reading of incoming lines and prompts - 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: Planned work, roughly in order:
1. Add MCCP2 compressed-stream support. 1. Add GMCP negotiation, bounded JSON parsing, typed package events, and an accessible inspector.
2. 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 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 aliases, triggers, timers, optional transcripts, and configurable command shortcuts. 4. Add multiple simultaneous sessions with independent output and speech queues.
5. Add multiple simultaneous sessions with independent output and speech queues. 5. Add localization catalogs and package/signing automation for releases.
6. Add localization catalogs and package/signing automation for releases.
## Running the tests ## Running the tests
+1 -1
View File
@@ -3,7 +3,7 @@
"id": "51a92051-84e0-415f-84e3-98f08a320a15", "id": "51a92051-84e0-415f-84e3-98f08a320a15",
"name": "Meow", "name": "Meow",
"version": "0.1.0", "version": "0.1.0",
"build_id": 2, "build_id": 3,
"EltenAPIVersion": "3.0.3", "EltenAPIVersion": "3.0.3",
"author": "Talon", "author": "Talon",
"main_language": "en", "main_language": "en",
+4
View File
@@ -27,6 +27,7 @@ module MeowClient
def run def run
@form.wait @form.wait
ensure ensure
@pipeline.close
@transport.close @transport.close
@program.release(@transport, :close => false) @program.release(@transport, :close => false)
end end
@@ -83,6 +84,9 @@ module MeowClient
receive_record(record) if record != nil receive_record(record) if record != nil
end end
dispatch_speech 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 rescue Exception => error
Log.error("Meow session update failed: #{error.class}: #{error.message}") if defined?(Log) Log.error("Meow session update failed: #{error.class}: #{error.message}") if defined?(Log)
add_system_line(_("Session update error: %{message}") % {:message => error.message}) add_system_line(_("Session update error: %{message}") % {:message => error.message})
+95 -38
View File
@@ -1,3 +1,5 @@
require "zlib"
module MeowClient module MeowClient
module Telnet module Telnet
IAC = 255 IAC = 255
@@ -11,10 +13,13 @@ module MeowClient
ECHO = 1 ECHO = 1
SUPPRESS_GO_AHEAD = 3 SUPPRESS_GO_AHEAD = 3
TERMINAL_TYPE = 24 TERMINAL_TYPE = 24
COMPRESS2 = 86
TTYPE_IS = 0 TTYPE_IS = 0
TTYPE_SEND = 1 TTYPE_SEND = 1
MAX_INFLATED_BYTES_PER_FEED = 1_048_576
Result = Struct.new(:data, :replies, :events, keyword_init: true) Result = Struct.new(:data, :replies, :events, keyword_init: true)
CompressionError = Class.new(StandardError)
class Parser class Parser
attr_reader :remote_echo attr_reader :remote_echo
@@ -27,56 +32,101 @@ module MeowClient
@remote_echo = false @remote_echo = false
@remote_options = {} @remote_options = {}
@local_options = {} @local_options = {}
@inflater = nil
@compression_finished = false
end end
def feed(bytes) def feed(bytes)
output = +"".b output = +"".b
replies = [] replies = []
events = [] events = []
bytes.to_s.b.each_byte do |byte| input = bytes.to_s.b
case @state if @compression_finished && input != ""
when :data raise CompressionError, "Data followed the end of the MCCP2 stream."
if byte == IAC end
@state = :iac
else index = 0
output << byte while index < input.bytesize
end if @inflater != nil
when :iac inflate(input.byteslice(index..-1), output, replies, events)
handle_iac(byte, output) index = input.bytesize
when :negotiation else
reply, event = negotiate(@negotiation_command, byte) consume_byte(input.getbyte(index), output, replies, events)
replies << reply if reply != nil index += 1
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
end end
Result.new(:data => output, :replies => replies, :events => events) 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 end
private 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) def handle_iac(byte, output)
case byte case byte
when IAC when IAC
@@ -99,7 +149,7 @@ module MeowClient
def negotiate(command, option) def negotiate(command, option)
case command case command
when WILL when WILL
enabled = [ECHO, SUPPRESS_GO_AHEAD].include?(option) enabled = [ECHO, SUPPRESS_GO_AHEAD, COMPRESS2].include?(option)
changed = @remote_options[option] != enabled changed = @remote_options[option] != enabled
@remote_options[option] = enabled @remote_options[option] = enabled
return [nil, nil] unless changed return [nil, nil] unless changed
@@ -131,6 +181,13 @@ module MeowClient
end end
def finish_subnegotiation 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 && if @subnegotiation_option == TERMINAL_TYPE &&
@local_options[TERMINAL_TYPE] == true && @local_options[TERMINAL_TYPE] == true &&
@subnegotiation.getbyte(0) == TTYPE_SEND @subnegotiation.getbyte(0) == TTYPE_SEND
+4
View File
@@ -94,6 +94,10 @@ module MeowClient
prompt == nil ? [] : [prompt] prompt == nil ? [] : [prompt]
end end
def close
@telnet.close
end
private private
def decode(bytes) def decode(bytes)
+102
View File
@@ -1,5 +1,7 @@
# encoding: UTF-8 # encoding: UTF-8
require "zlib"
require_relative "test_helper" require_relative "test_helper"
require_relative "../lib/meow/telnet" require_relative "../lib/meow/telnet"
@@ -94,4 +96,104 @@ module MeowTelnetTest
MeowTest.assert_empty(result.replies) MeowTest.assert_empty(result.replies)
MeowTest.assert_empty(result.events) MeowTest.assert_empty(result.events)
end 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 end
+10
View File
@@ -29,6 +29,16 @@ module MeowTest
assert(value.empty?, message || "Expected #{value.inspect} to be empty") assert(value.empty?, message || "Expected #{value.inspect} to be empty")
end 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! def run!
failures = [] failures = []
@tests.each do |name, test| @tests.each do |name, test|
+16
View File
@@ -1,5 +1,7 @@
# encoding: UTF-8 # encoding: UTF-8
require "zlib"
require_relative "test_helper" require_relative "test_helper"
require_relative "../lib/meow/telnet" require_relative "../lib/meow/telnet"
require_relative "../lib/meow/text_pipeline" require_relative "../lib/meow/text_pipeline"
@@ -74,4 +76,18 @@ module MeowTextPipelineTest
MeowTest.assert_equal([[:remote_echo, true]], result.events) MeowTest.assert_equal([[:remote_echo, true]], result.events)
MeowTest.assert(pipeline.remote_echo?) MeowTest.assert(pipeline.remote_echo?)
end 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 end