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
|
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user