crussh 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +371 -0
  5. data/ext/poly1305/Cargo.toml +13 -0
  6. data/ext/poly1305/extconf.rb +6 -0
  7. data/ext/poly1305/src/lib.rs +75 -0
  8. data/lib/crussh/auth.rb +46 -0
  9. data/lib/crussh/channel/key_parser.rb +125 -0
  10. data/lib/crussh/channel.rb +381 -0
  11. data/lib/crussh/cipher/algorithm.rb +31 -0
  12. data/lib/crussh/cipher/chacha20poly1305.rb +98 -0
  13. data/lib/crussh/cipher.rb +25 -0
  14. data/lib/crussh/compression.rb +42 -0
  15. data/lib/crussh/gatekeeper.rb +50 -0
  16. data/lib/crussh/handler/line_buffer.rb +131 -0
  17. data/lib/crussh/handler.rb +128 -0
  18. data/lib/crussh/heartbeat.rb +68 -0
  19. data/lib/crussh/kex/algorithm.rb +86 -0
  20. data/lib/crussh/kex/curve25519.rb +30 -0
  21. data/lib/crussh/kex/exchange.rb +234 -0
  22. data/lib/crussh/kex.rb +42 -0
  23. data/lib/crussh/keys/key_pair.rb +61 -0
  24. data/lib/crussh/keys/public_key.rb +35 -0
  25. data/lib/crussh/keys.rb +70 -0
  26. data/lib/crussh/limits.rb +45 -0
  27. data/lib/crussh/logger.rb +95 -0
  28. data/lib/crussh/mac/algorithm.rb +23 -0
  29. data/lib/crussh/mac/crypto.rb +60 -0
  30. data/lib/crussh/mac/none.rb +9 -0
  31. data/lib/crussh/mac.rb +28 -0
  32. data/lib/crussh/negotiator.rb +41 -0
  33. data/lib/crussh/preferred.rb +16 -0
  34. data/lib/crussh/protocol/channel_close.rb +11 -0
  35. data/lib/crussh/protocol/channel_data.rb +12 -0
  36. data/lib/crussh/protocol/channel_eof.rb +11 -0
  37. data/lib/crussh/protocol/channel_extended_data.rb +13 -0
  38. data/lib/crussh/protocol/channel_failure.rb +11 -0
  39. data/lib/crussh/protocol/channel_open.rb +69 -0
  40. data/lib/crussh/protocol/channel_open_confirmation.rb +15 -0
  41. data/lib/crussh/protocol/channel_open_failure.rb +14 -0
  42. data/lib/crussh/protocol/channel_request.rb +146 -0
  43. data/lib/crussh/protocol/channel_success.rb +11 -0
  44. data/lib/crussh/protocol/channel_window_adjust.rb +12 -0
  45. data/lib/crussh/protocol/debug.rb +15 -0
  46. data/lib/crussh/protocol/disconnect.rb +39 -0
  47. data/lib/crussh/protocol/ext_info.rb +48 -0
  48. data/lib/crussh/protocol/global_request.rb +46 -0
  49. data/lib/crussh/protocol/ignore.rb +11 -0
  50. data/lib/crussh/protocol/kex_ecdh_init.rb +11 -0
  51. data/lib/crussh/protocol/kex_ecdh_reply.rb +13 -0
  52. data/lib/crussh/protocol/kex_init.rb +38 -0
  53. data/lib/crussh/protocol/new_keys.rb +9 -0
  54. data/lib/crussh/protocol/ping.rb +11 -0
  55. data/lib/crussh/protocol/pong.rb +11 -0
  56. data/lib/crussh/protocol/request_failure.rb +9 -0
  57. data/lib/crussh/protocol/request_success.rb +11 -0
  58. data/lib/crussh/protocol/service_accept.rb +11 -0
  59. data/lib/crussh/protocol/service_request.rb +11 -0
  60. data/lib/crussh/protocol/unimplemented.rb +11 -0
  61. data/lib/crussh/protocol/userauth_banner.rb +12 -0
  62. data/lib/crussh/protocol/userauth_failure.rb +12 -0
  63. data/lib/crussh/protocol/userauth_pk_ok.rb +12 -0
  64. data/lib/crussh/protocol/userauth_request.rb +52 -0
  65. data/lib/crussh/protocol/userauth_success.rb +9 -0
  66. data/lib/crussh/protocol.rb +135 -0
  67. data/lib/crussh/server/auth_handler.rb +18 -0
  68. data/lib/crussh/server/config.rb +157 -0
  69. data/lib/crussh/server/layers/connection.rb +363 -0
  70. data/lib/crussh/server/layers/transport.rb +49 -0
  71. data/lib/crussh/server/layers/userauth.rb +232 -0
  72. data/lib/crussh/server/request_rule.rb +76 -0
  73. data/lib/crussh/server/session.rb +192 -0
  74. data/lib/crussh/server.rb +214 -0
  75. data/lib/crussh/ssh_id.rb +44 -0
  76. data/lib/crussh/transport/packet_stream.rb +245 -0
  77. data/lib/crussh/transport/reader.rb +98 -0
  78. data/lib/crussh/transport/version_exchange.rb +26 -0
  79. data/lib/crussh/transport/writer.rb +72 -0
  80. data/lib/crussh/version.rb +5 -0
  81. data/lib/crussh.rb +61 -0
  82. data/sig/crussh.rbs +4 -0
  83. metadata +249 -0
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crussh
4
+ class Server
5
+ class << self
6
+ def configure
7
+ yield config
8
+ end
9
+
10
+ def config
11
+ @config ||= Config.new
12
+ end
13
+
14
+ def banner(text = nil, &block)
15
+ return @banner = block if block
16
+
17
+ @banner = text
18
+ end
19
+
20
+ def read_banner
21
+ @banner.is_a?(Proc) ? @banner.call : @banner
22
+ end
23
+
24
+ def authenticate(method, &block)
25
+ auth_handlers[method] = AuthHandler.new(block)
26
+ end
27
+
28
+ def auth_handlers
29
+ @auth_handlers ||= {}
30
+ end
31
+
32
+ def accept(*types, only: nil, except: nil, if: nil, unless: nil, &block)
33
+ rule = RequestRule.accept(
34
+ only: only,
35
+ except: except,
36
+ if: binding.local_variable_get(:if),
37
+ unless: binding.local_variable_get(:unless),
38
+ &block
39
+ )
40
+
41
+ types.each { |type| request_rules[type] = rule }
42
+ end
43
+
44
+ def reject(*types)
45
+ rule = RequestRule.reject
46
+
47
+ types.each { |type| request_rules[type] = rule }
48
+ end
49
+
50
+ def request_rules
51
+ @request_rules ||= {}
52
+ end
53
+
54
+ def handle(type, handler = nil, &block)
55
+ handlers[type] = handler || block
56
+ end
57
+
58
+ def handlers
59
+ @handlers ||= {}
60
+ end
61
+
62
+ def inherited(subclass)
63
+ subclass.instance_variable_set(:@config, config.dup)
64
+ subclass.instance_variable_set(:@auth_handlers, auth_handlers.dup)
65
+ subclass.instance_variable_set(:@banner, @banner)
66
+
67
+ super
68
+ end
69
+
70
+ def run(**options)
71
+ new(**options).run
72
+ end
73
+ end
74
+
75
+ def initialize(**config)
76
+ @config = self.class.config.dup
77
+
78
+ config.each do |key, value|
79
+ @config.public_send(:"#{key}=", value)
80
+ end
81
+
82
+ @config.validate!
83
+
84
+ @gatekeeper = Gatekeeper.new(
85
+ max_connections: @config.max_connections,
86
+ max_unauthenticated: @config.max_unauthenticated,
87
+ )
88
+ end
89
+
90
+ attr_reader :config, :gatekeeper
91
+
92
+ def run
93
+ Logger.info(self, "Starting server", host: config.host, port: config.port)
94
+
95
+ endpoint = IO::Endpoint.tcp(config.host, config.port)
96
+
97
+ Async do |task|
98
+ endpoint.bind do |server|
99
+ loop do
100
+ socket, address = server.accept
101
+
102
+ task.async do
103
+ handle_connection(socket, address)
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ def handle_auth(method, *args)
111
+ handler = self.class.auth_handlers[method]
112
+ return Auth.reject unless handler
113
+
114
+ handler.call(*args)
115
+ end
116
+
117
+ def auth_methods
118
+ self.class.auth_handlers.keys
119
+ end
120
+
121
+ def banner
122
+ self.class.read_banner
123
+ end
124
+
125
+ def accepts_channel?(type)
126
+ case type
127
+ when :session
128
+ has_handler?(:shell) || has_handler?(:exec) || has_handler?(:subsystem)
129
+ when :direct_tcpip
130
+ has_handler?(:direct_tcpip)
131
+ when :forwarded_tcpip
132
+ has_handler?(:forwarded_tcpip)
133
+ when :x11
134
+ has_handler?(:x11)
135
+ else
136
+ false
137
+ end
138
+ end
139
+
140
+ def open_channel?(type, channel, ...)
141
+ method_name = :"open_#{type}?"
142
+
143
+ return true unless respond_to?(method_name)
144
+
145
+ send(method_name, channel, ...)
146
+ end
147
+
148
+ def accepts_request?(type, channel, **params)
149
+ rule = self.class.request_rules[type]
150
+
151
+ return type == :pty if rule.nil?
152
+
153
+ rule.allowed?(channel, **params)
154
+ end
155
+
156
+ def dispatch_handler(type, channel, session, *args)
157
+ handler_class_or_proc = self.class.handlers[type]
158
+ return false unless handler_class_or_proc
159
+
160
+ case handler_class_or_proc
161
+ when Class
162
+ handler = handler_class_or_proc.new(channel, session, *args)
163
+ handler.call
164
+ when Proc, Method
165
+ handler_class_or_proc.call(channel, session, *args)
166
+ end
167
+
168
+ true
169
+ end
170
+
171
+ def has_handler?(type)
172
+ self.class.handlers.key?(type)
173
+ end
174
+
175
+ private
176
+
177
+ def handle_connection(socket, address)
178
+ peer = format_address(address)
179
+
180
+ Logger.info(self, "New connection", peer:)
181
+
182
+ if @gatekeeper.block?
183
+ Logger.warn(self, "Connection rejected, limit reached", peer: peer, **@gatekeeper.stats)
184
+ socket.close
185
+ return
186
+ end
187
+
188
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if config.nodelay?
189
+
190
+ session = Session.new(socket, server: self)
191
+ session.start
192
+
193
+ Logger.info(self, "connection closed", peer:)
194
+ rescue => e
195
+ Logger.error(self, "Connection error", error: e.message)
196
+ ensure
197
+ @gatekeeper.disconnect!(was_authenticated: !session&.user.nil?)
198
+ begin
199
+ socket.close unless socket.closed?
200
+ rescue
201
+ nil
202
+ end
203
+ end
204
+
205
+ def format_address(address)
206
+ case address
207
+ when Addrinfo
208
+ "#{address.ip_address}:#{address.ip_port}"
209
+ else
210
+ address.to_s
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crussh
4
+ class SshId
5
+ PROTO_VERSION = "2.0"
6
+
7
+ def initialize(software_version, comments: nil)
8
+ @software_version = software_version
9
+ @comments = comments
10
+ end
11
+
12
+ class << self
13
+ def parse(line)
14
+ line = line.chomp
15
+ raise ProtocolError, "Invalid SSH identification: #{line.inspect}" unless line.start_with?("SSH-")
16
+
17
+ parts = line.split("-", 3)
18
+ raise ProtocolError, "Invalid SSH identification" if parts.length < 3
19
+
20
+ proto_version = parts[1]
21
+ unless proto_version == "2.0" || proto_version.start_with?("2.")
22
+ raise ProtocolError, "Unsupported SSH protocol version: #{proto_version}"
23
+ end
24
+
25
+ software_and_comments = parts[2]
26
+ software_version, comments = software_and_comments.split(" ", 2)
27
+
28
+ new(software_version, comments: comments)
29
+ end
30
+ end
31
+
32
+ attr_reader :software_version, :comments
33
+
34
+ def to_s
35
+ base = "SSH-#{PROTO_VERSION}-#{@software_version}"
36
+ base += " #{@comments}" if @comments
37
+ base
38
+ end
39
+
40
+ def serialize
41
+ "#{self}\r\n"
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/stream"
4
+
5
+ module Crussh
6
+ module Transport
7
+ class PacketStream
8
+ MIN_PADDING = 4
9
+ MAX_PADDING = 255
10
+ MIN_PACKET_SIZE = 5
11
+
12
+ BLOCK_SIZE = 8
13
+
14
+ def initialize(socket, max_packet_size:)
15
+ @stream = IO::Stream(socket)
16
+ @reader = Reader.new(@stream, max_packet_size)
17
+ @writer = Writer.new(@stream)
18
+ end
19
+
20
+ def read = @reader.read
21
+ def write(...) = @writer.write(...)
22
+
23
+ def enable_encryption(opening_key, sealing_key)
24
+ @reader.enable_encryption(opening_key)
25
+ @writer.enable_encryption(sealing_key)
26
+ end
27
+
28
+ def enable_compression(read_compressor, write_compressor)
29
+ @reader.enable_compression(read_compressor)
30
+ @writer.enable_compression(write_compressor)
31
+ end
32
+
33
+ def last_read_sequence = @reader.last_sequence
34
+ def sequence_wrapped? = @reader.sequence_wrapped?
35
+
36
+ def reset_sequence
37
+ @reader.reset_sequence
38
+ @writer.reset_sequence
39
+ end
40
+
41
+ class Writer
42
+ def initialize(stream)
43
+ @stream = stream
44
+ @sequence = 0
45
+ @sealing_key = nil
46
+
47
+ @compressor = Compression::None.new
48
+ end
49
+
50
+ def encrypted?
51
+ !@sealing_key.nil?
52
+ end
53
+
54
+ def write(data)
55
+ data = @compressor.deflate(data)
56
+
57
+ if encrypted?
58
+ write_encrypted(data)
59
+ else
60
+ write_unencrypted(data)
61
+ end
62
+
63
+ @stream.flush
64
+ increment_sequence
65
+ end
66
+
67
+ def enable_encryption(sealing_key)
68
+ @sealing_key = sealing_key
69
+ end
70
+
71
+ def enable_compression(compressor)
72
+ @compressor = compressor
73
+ end
74
+
75
+ def reset_sequence
76
+ @sequence = 0
77
+ end
78
+
79
+ private
80
+
81
+ def increment_sequence
82
+ @sequence = (@sequence + 1) & 0xFFFFFFFF
83
+ end
84
+
85
+ def write_encrypted(data)
86
+ padded = pad(data)
87
+
88
+ length_bytes = [padded.bytesize].pack("N")
89
+ encrypted_length = @sealing_key.encrypt_length(@sequence, length_bytes)
90
+
91
+ ciphertext, tag = @sealing_key.seal(@sequence, encrypted_length, padded)
92
+
93
+ @stream.write(encrypted_length + ciphertext + tag)
94
+ end
95
+
96
+ def write_unencrypted(data)
97
+ padded = pad(data)
98
+ packet = [padded.bytesize].pack("N") + padded
99
+
100
+ @stream.write(packet)
101
+ end
102
+
103
+ def pad(data)
104
+ data = data.b
105
+ payload_length = data.bytesize
106
+
107
+ min_total = 1 + payload_length + MIN_PADDING
108
+ min_total += 4 unless encrypted?
109
+
110
+ extra = (BLOCK_SIZE - (min_total % BLOCK_SIZE)) % BLOCK_SIZE
111
+ padding_length = MIN_PADDING + extra
112
+
113
+ padding_bytes = SecureRandom.random_bytes(padding_length)
114
+
115
+ [padding_length].pack("C") + data + padding_bytes
116
+ end
117
+ end
118
+
119
+ class Reader
120
+ def initialize(stream, max_packet_size)
121
+ @stream = stream
122
+ @max_packet_size = max_packet_size
123
+
124
+ @sequence = 0
125
+ @last_sequence = 0
126
+ @wrapped = false
127
+
128
+ @compressor = Compression::None.new
129
+ end
130
+
131
+ attr_reader :last_sequence
132
+
133
+ def encrypted?
134
+ !@opening_key.nil?
135
+ end
136
+
137
+ def read
138
+ @last_sequence = @sequence
139
+
140
+ result = if encrypted?
141
+ read_encrypted
142
+ else
143
+ read_unencrypted
144
+ end
145
+
146
+ increment_sequence
147
+
148
+ @compressor.inflate(result)
149
+ end
150
+
151
+ def enable_encryption(opening_key)
152
+ @opening_key = opening_key
153
+ end
154
+
155
+ def enable_compression(compressor)
156
+ @compressor = compressor
157
+ end
158
+
159
+ def reset_sequence
160
+ @sequence = 0
161
+ @last_sequence = 0
162
+ @wrapped = false
163
+ end
164
+
165
+ def sequence_wrapped? = @wrapped
166
+
167
+ private
168
+
169
+ def increment_sequence
170
+ @wrapped = true if @sequence == 0xFFFFFFFF
171
+ @sequence = (@sequence + 1) & 0xFFFFFFFF
172
+ end
173
+
174
+ def read_unencrypted
175
+ length_bytes = @stream.read_exactly(4)
176
+ packet_length = length_bytes.unpack1("N")
177
+
178
+ validate_packet_length!(packet_length)
179
+
180
+ data = @stream.read_exactly(packet_length)
181
+
182
+ unwrap(data)
183
+ end
184
+
185
+ def read_encrypted
186
+ encrypted_length = @stream.read_exactly(4)
187
+
188
+ length_bytes = @opening_key.decrypt_length(@sequence, encrypted_length)
189
+ packet_length = length_bytes.unpack1("N")
190
+
191
+ validate_packet_length!(packet_length)
192
+
193
+ ciphertext = @stream.read_exactly(packet_length)
194
+ tag = @stream.read_exactly(16)
195
+
196
+ plaintext = @opening_key.open(@sequence, encrypted_length, ciphertext, tag)
197
+
198
+ unwrap(plaintext)
199
+ end
200
+
201
+ def unwrap(data)
202
+ return if data.empty?
203
+
204
+ padding_length = data.getbyte(0)
205
+
206
+ validate_padding_length!(padding_length, data.bytesize)
207
+
208
+ payload_length = data.bytesize - padding_length - 1
209
+
210
+ validate_payload_length!(payload_length)
211
+
212
+ data.byteslice(1, payload_length)
213
+ end
214
+
215
+ def validate_packet_length!(packet_length)
216
+ if packet_length < MIN_PACKET_SIZE
217
+ raise PacketTooSmall, "Packet length #{packet_length} below minimum #{MIN_PACKET_SIZE}"
218
+ end
219
+
220
+ return if packet_length <= @max_packet_size
221
+
222
+ raise PacketTooLarge, "Packet length #{packet_length} exceeds maximum #{@max_packet_size}"
223
+ end
224
+
225
+ def validate_padding_length!(padding_length, packet_length)
226
+ if padding_length < MIN_PADDING
227
+ raise InvalidPadding, "Padding length #{padding_length} below minimum #{MIN_PADDING}"
228
+ end
229
+
230
+ if padding_length > MAX_PADDING
231
+ raise InvalidPadding, "Padding length #{padding_length} exceeds maximum #{MAX_PADDING}"
232
+ end
233
+
234
+ return if padding_length < packet_length
235
+
236
+ raise InvalidPadding, "Padding length #{padding_length} >= packet length #{packet_length}"
237
+ end
238
+
239
+ def validate_payload_length!(payload_length)
240
+ raise PacketTooSmall, "Invalid payload length: #{payload_length}" if payload_length.negative?
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crussh
4
+ module Transport
5
+ class Reader
6
+ def initialize(data)
7
+ @data = data.b
8
+ @position = 0
9
+ end
10
+
11
+ def byte
12
+ ensure_remaining!(1)
13
+
14
+ value = @data.getbyte(@position)
15
+
16
+ @position += 1
17
+
18
+ value
19
+ end
20
+
21
+ def boolean
22
+ byte != 0
23
+ end
24
+
25
+ def uint32
26
+ ensure_remaining!(4)
27
+
28
+ value = @data[@position, 4].unpack1("N")
29
+
30
+ @position += 4
31
+
32
+ value
33
+ end
34
+
35
+ def string(max_length: nil)
36
+ length = uint32
37
+
38
+ raise PacketTooLarge, "String length #{length} exceeds maximum #{max_length}" if max_length && length > max_length
39
+
40
+ ensure_remaining!(length)
41
+
42
+ value = @data[@position, length]
43
+
44
+ @position += length
45
+
46
+ value
47
+ end
48
+
49
+ def read(n)
50
+ ensure_remaining!(n)
51
+
52
+ value = @data[@position, n]
53
+
54
+ @position += n
55
+
56
+ value
57
+ end
58
+
59
+ def mpint
60
+ raw = string
61
+
62
+ return OpenSSL::BN.new(0) if raw.empty?
63
+
64
+ OpenSSL::BN.new(raw, 2)
65
+ end
66
+
67
+ def name_list
68
+ string.split(",")
69
+ end
70
+
71
+ def remaining
72
+ @data.byteslice(@position..)
73
+ end
74
+
75
+ def remaining_bytes
76
+ @data.bytesize - @position
77
+ end
78
+
79
+ def skip(n)
80
+ ensure_remaining!(n)
81
+
82
+ @position += n
83
+ end
84
+
85
+ def eof?
86
+ @position >= @data.bytesize
87
+ end
88
+
89
+ private
90
+
91
+ def ensure_remaining!(n)
92
+ return if @position + n <= @data.bytesize
93
+
94
+ raise IncompletePacket, "Need #{n} bytes but only #{remaining_bytes} available"
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crussh
4
+ module Transport
5
+ class VersionExchange
6
+ MAX_VERSION_LENGTH = 255
7
+
8
+ def initialize(socket, server_id:)
9
+ @socket = socket
10
+ @server_id = server_id
11
+ end
12
+
13
+ def exchange
14
+ @socket.write(@server_id.serialize)
15
+
16
+ loop do
17
+ line = @socket.readline(MAX_VERSION_LENGTH, chomp: true)
18
+
19
+ next unless line.start_with?("SSH-")
20
+
21
+ return SshId.parse(line)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crussh
4
+ module Transport
5
+ class Writer
6
+ def initialize
7
+ @buffer = String.new(encoding: Encoding::BINARY, capacity: 256)
8
+ end
9
+
10
+ def byte(value)
11
+ @buffer << [value].pack("C")
12
+ self
13
+ end
14
+
15
+ def boolean(value)
16
+ byte(value ? 1 : 0)
17
+ end
18
+
19
+ def uint32(value)
20
+ @buffer << [value].pack("N")
21
+ self
22
+ end
23
+
24
+ def string(value)
25
+ value = value.b if value.is_a?(String)
26
+ uint32(value.bytesize)
27
+ @buffer << value
28
+ self
29
+ end
30
+
31
+ def name_list(names)
32
+ string(names.join(","))
33
+ end
34
+
35
+ def raw(bytes)
36
+ @buffer << bytes
37
+ self
38
+ end
39
+
40
+ def remaining(value)
41
+ raw(value.b)
42
+ self
43
+ end
44
+
45
+ def mpint(bn)
46
+ bn = OpenSSL::BN.new(bn) if bn.is_a?(Integer)
47
+
48
+ return uint32(0) if bn.zero?
49
+
50
+ raw_bytes = bn.to_s(2)
51
+
52
+ # two's compliment moment
53
+ raw_bytes = "\u0000#{raw_bytes}" if raw_bytes.getbyte(0) & 0x80 != 0
54
+
55
+ string(raw_bytes)
56
+ end
57
+
58
+ def to_s
59
+ @buffer.dup
60
+ end
61
+
62
+ def length
63
+ @buffer.bytesize
64
+ end
65
+
66
+ def reset
67
+ @buffer.clear
68
+ self
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crussh
4
+ VERSION = "0.1.0"
5
+ end