Add MCCP2 compressed stream support
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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})
|
||||
|
||||
+95
-38
@@ -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
|
||||
|
||||
@@ -94,6 +94,10 @@ module MeowClient
|
||||
prompt == nil ? [] : [prompt]
|
||||
end
|
||||
|
||||
def close
|
||||
@telnet.close
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def decode(bytes)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user