diff --git a/.codeclimate.yml b/.codeclimate.yml index 50537db5..b86b6ea6 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -9,7 +9,6 @@ engines: enabled: true rubocop: enabled: true - channel: rubocop-0-52 ratings: paths: - "**.rb" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 945e1c38..66d22f60 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,9 +9,19 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Release Gem - uses: cadwallion/publish-rubygems-action@master + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + + - name: Build and publish gem env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RUBYGEMS_API_KEY: ${{secrets.RUBYGEMS_API_KEY}} + RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} + run: | + mkdir -p ~/.gem + printf -- "---\n:rubygems_api_key: %s\n" "$RUBYGEMS_API_KEY" > ~/.gem/credentials + chmod 0600 ~/.gem/credentials + gem build websocket.gemspec + gem push websocket-*.gem diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6dcb7584..81f5b627 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,12 +13,12 @@ jobs: runs-on: ubuntu-latest name: Rubocop steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: '3.0' + ruby-version: '3.3' - name: Install gems run: bundle install @@ -42,13 +42,14 @@ jobs: - 3.2 - 3.3 - 3.4 + - "4.0" - jruby runs-on: ubuntu-latest name: Ruby ${{ matrix.ruby }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/.gitignore b/.gitignore index 99c92528..5b76d875 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ Gemfile.lock autobahn pkg/*.gem +coverage/ diff --git a/.rubocop.yml b/.rubocop.yml index 41b26b51..83ff0390 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,16 +1,16 @@ -require: rubocop-rspec +plugins: + - rubocop-rspec AllCops: DisplayCopNames: true TargetRubyVersion: 2.1 + NewCops: disable + SuggestExtensions: false # New version of Rubocop does not support 2.0 Gemspec/RequiredRubyVersion: Enabled: false -Layout/IndentHeredoc: - Enabled: false - # Target: 15 Metrics/AbcSize: Max: 24 @@ -30,8 +30,9 @@ Metrics/CyclomaticComplexity: Max: 11 Exclude: - spec/support/handshake_requests.rb + - lib/websocket/handshake/client.rb -Metrics/LineLength: +Layout/LineLength: Enabled: false # Target: 10 @@ -46,6 +47,13 @@ Metrics/PerceivedComplexity: Exclude: - lib/websocket/frame/handler/handler75.rb - spec/support/handshake_requests.rb + - lib/websocket/handshake/client.rb + +# Draft numbers (75, 76, 04, 11...) are part of the protocol version they +# implement, not counters - renaming them would obscure their meaning. +Naming/VariableNumber: + Exclude: + - spec/support/handshake_requests.rb RSpec/ContextWording: Enabled: false @@ -62,8 +70,30 @@ RSpec/InstanceVariable: RSpec/MultipleExpectations: Enabled: false +RSpec/MultipleMemoizedHelpers: + Enabled: false + RSpec/NamedSubject: Enabled: false +# These examples assert via a shared `validate_request` helper method, which +# this cop cannot see into, producing a false positive. +RSpec/NoExpectationExample: + Exclude: + - spec/support/all_client_drafts.rb + - spec/support/all_server_drafts.rb + +# This codebase names spec files/directories after what they describe (e.g. +# spec/frame/data_spec.rb for WebSocket::Frame::Data), not after the full +# `WebSocket::` constant path the cop expects. +RSpec/SpecFilePathFormat: + Enabled: false + +# @frame_type is intentionally shared state between decode_continuation_frame +# and decode_finish_continuation_frame, not a per-method memoization. +Naming/MemoizedInstanceVariableName: + Exclude: + - lib/websocket/frame/handler/handler03.rb + Style/Documentation: Enabled: false diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 00000000..d13e837c --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c1ac99e..24a79274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Edge +- fix `bundle install`/`rspec` on Ruby 2.1-3.1: gate `rubocop`, `rubocop-rspec` (require Ruby >= 2.7) and `simplecov` (requires Ruby >= 3.2) behind Ruby-version checks in the Gemfile, and make `spec_helper.rb` tolerate simplecov not being installed +- add Ruby 4.0 to the CI test matrix and verify the full suite passes on it +- upgrade development dependencies to current, maintained versions (rspec, rubocop, rubocop-rspec, rake, webrick) +- add bundler-audit and simplecov to the development toolchain +- raise `required_ruby_version` to match the Ruby versions actually covered by CI +- replace the abandoned, unpinned `cadwallion/publish-rubygems-action` in the release workflow with a plain `gem build`/`gem push` step +- reach 100% line and branch test coverage +- add YARD documentation to previously undocumented public classes and methods + ## 1.2.11 - remove unused base64 require that would cause issues in Ruby 3.4 diff --git a/Gemfile b/Gemfile index c49be77f..4a6a1b30 100644 --- a/Gemfile +++ b/Gemfile @@ -1,16 +1,30 @@ # frozen_string_literal: true -source 'http://rubygems.org' +source 'https://rubygems.org' + +running_ruby_version = Gem::Version.new(RUBY_VERSION) group :development do + gem 'bundler-audit', '~> 0.9', require: false gem 'rake' - gem 'rspec', '~> 3.7' + gem 'rspec', '~> 3.13' gem 'webrick' - # Use same version as Code Climate for consistency with CI - # https://github.com/codeclimate/codeclimate-rubocop/blob/master/Gemfile.lock - gem 'rubocop', '0.52.1', require: false - gem 'rubocop-rspec', '1.21.0', require: false + # rubocop/rubocop-rspec 1.88.x/3.10.x require Ruby >= 2.7; the test matrix + # goes back to Ruby 2.1, so these are dev-lint-only and gated to keep + # `bundle install` working on every Ruby the test suite still supports. + if running_ruby_version >= Gem::Version.new('2.7') + gem 'rubocop', '~> 1.88', require: false + gem 'rubocop-rspec', '~> 3.10', require: false + end + + # simplecov 1.0.x requires Ruby >= 3.2. JRuby is excluded even when it + # reports a compatible RUBY_VERSION: JRuby's Coverage data is documented + # (by simplecov itself, and github.com/jruby/jruby#1196) to undercount + # unless JRuby's full-trace mode is explicitly enabled, which this CI + # config does not do -- so the 100% `minimum_coverage` gate below would + # spuriously fail there even though the suite itself passes. + gem 'simplecov', '~> 1.0', require: false if RUBY_ENGINE != 'jruby' && running_ruby_version >= Gem::Version.new('3.2') end gemspec diff --git a/lib/websocket.rb b/lib/websocket.rb index 6fadee7b..9ec982b0 100644 --- a/lib/websocket.rb +++ b/lib/websocket.rb @@ -36,11 +36,16 @@ def self.should_raise def self.should_raise=(val) @should_raise = val end -end -# Try loading websocket-native if available -begin - require 'websocket-native' -rescue LoadError => e - raise unless e.message =~ /websocket-native/ + # Attempt to load the optional `websocket-native` gem, which provides a native + # (C/Java) implementation of frame masking for extra speed. This is a no-op if + # the gem is not installed; any other LoadError is re-raised. + # @see https://github.com/imanel/websocket-ruby-native + def self.load_native_extension + require 'websocket-native' + rescue LoadError => e + raise unless e.message =~ /websocket-native/ + end end + +WebSocket.load_native_extension diff --git a/lib/websocket/error.rb b/lib/websocket/error.rb index ec63e9f7..4a9f3233 100644 --- a/lib/websocket/error.rb +++ b/lib/websocket/error.rb @@ -1,80 +1,104 @@ # frozen_string_literal: true module WebSocket + # Base class for all errors raised by this library. Every error's #message + # returns a Symbol (rather than a String) describing the failure, which is + # also the value exposed via `error` on frames and handshakes when + # {WebSocket.should_raise} is false. class Error < RuntimeError + # Errors raised while encoding or decoding a {WebSocket::Frame}. class Frame < ::WebSocket::Error + # A control frame (close/ping/pong) declared a payload larger than the + # 125-byte limit imposed by the spec. class ControlFramePayloadTooLong < ::WebSocket::Error::Frame def message :control_frame_payload_too_long end end + # A text or binary frame was received while a previous fragmented + # message was still awaiting its continuation frame. class DataFrameInsteadContinuation < ::WebSocket::Error::Frame def message :data_frame_instead_continuation end end + # A control frame (close/ping/pong) was sent as a fragment, which the + # spec forbids - control frames must never be fragmented. class FragmentedControlFrame < ::WebSocket::Error::Frame def message :fragmented_control_frame end end + # A hixie-75 draft frame did not match the expected `\x00...\xff` framing. class Invalid < ::WebSocket::Error::Frame def message :invalid_frame end end + # A text frame's payload (or the concatenated payload of a fragmented + # message) was not valid UTF-8. class InvalidPayloadEncoding < ::WebSocket::Error::Frame def message :invalid_payload_encoding end end + # A masked frame's payload was shorter than the 4-byte masking key. class MaskTooShort < ::WebSocket::Error::Frame def message :mask_is_too_short end end + # A frame used one of the reserved RSV bits, which is only permitted + # when negotiated via an extension this library does not implement. class ReservedBitUsed < ::WebSocket::Error::Frame def message :reserved_bit_used end end + # A frame declared a payload length larger than {WebSocket.max_frame_size}. class TooLong < ::WebSocket::Error::Frame def message :frame_too_long end end + # A continuation frame was received without a preceding fragmented + # (more-bit/fin-bit) frame to continue. class UnexpectedContinuationFrame < ::WebSocket::Error::Frame def message :unexpected_continuation_frame end end + # A frame's opcode did not map to any frame type known by the handler. class UnknownFrameType < ::WebSocket::Error::Frame def message :unknown_frame_type end end + # A frame's opcode byte did not match any opcode known by the handler. class UnknownOpcode < ::WebSocket::Error::Frame def message :unknown_opcode end end + # A close frame carried a status code outside the ranges permitted by the spec. class UnknownCloseCode < ::WebSocket::Error::Frame def message :unknown_close_code end end + # A frame was constructed with a protocol version not supported by this library. class UnknownVersion < ::WebSocket::Error::Frame def message :unknown_protocol_version @@ -82,43 +106,54 @@ def message end end + # Errors raised while constructing or parsing a {WebSocket::Handshake}. class Handshake < ::WebSocket::Error + # A server received a request whose method was not GET. class GetRequestRequired < ::WebSocket::Error::Handshake def message :get_request_required end end + # The handshake's authentication challenge (Sec-WebSocket-Accept, hixie-76 + # challenge response, etc.) did not match the expected value. class InvalidAuthentication < ::WebSocket::Error::Handshake def message :invalid_handshake_authentication end end + # The request/response's first line (request line or status line) could not be parsed. class InvalidHeader < ::WebSocket::Error::Handshake def message :invalid_header end end + # None of the client's requested sub-protocols were supported by the server, or + # vice versa. class UnsupportedProtocol < ::WebSocket::Error::Handshake def message :unsupported_protocol end end + # A client received a response whose HTTP status code was not 101 (Switching Protocols). class InvalidStatusCode < ::WebSocket::Error::Handshake def message :invalid_status_code end end + # A client handshake was constructed without a :host, :url or :uri option. class NoHostProvided < ::WebSocket::Error::Handshake def message :no_host_provided end end + # A handshake was constructed, or detected, with a protocol version not + # supported by this library. class UnknownVersion < ::WebSocket::Error::Handshake def message :unknown_protocol_version diff --git a/lib/websocket/frame/data.rb b/lib/websocket/frame/data.rb index 3c799404..214d320f 100644 --- a/lib/websocket/frame/data.rb +++ b/lib/websocket/frame/data.rb @@ -20,6 +20,7 @@ def convert_args(args) # Extract mask from 4 first bytes according to spec def set_mask raise WebSocket::Error::Frame::MaskTooShort if bytesize < 4 + @masking_key = self[0..3].bytes.to_a end @@ -38,6 +39,7 @@ def getbytes(start_index, count) # Mask whole payload using mask key def mask(payload, mask) return mask_native(payload, mask) if respond_to?(:mask_native) + result = [] payload.each_with_index do |byte, i| result[i] = byte ^ mask[i % 4] diff --git a/lib/websocket/frame/handler/handler03.rb b/lib/websocket/frame/handler/handler03.rb index 16e01fb9..8db4d62a 100644 --- a/lib/websocket/frame/handler/handler03.rb +++ b/lib/websocket/frame/handler/handler03.rb @@ -6,6 +6,9 @@ module WebSocket module Frame module Handler + # Frame encoder/decoder for hybi drafts 00-03 of the protocol. + # Introduces binary framing with opcodes, the "more" continuation bit, + # extended payload lengths and (unlike hixie) no payload masking. class Handler03 < Base # Hash of frame names and it's opcodes FRAME_TYPES = { @@ -20,6 +23,7 @@ class Handler03 < Base # Hash of frame opcodes and it's names FRAME_TYPES_INVERSE = FRAME_TYPES.invert.freeze + # @param frame [WebSocket::Frame::Base] Frame that owns this handler def initialize(frame) super @application_data_buffer = nil @@ -58,13 +62,15 @@ def decode_frame return decode_finish_continuation_frame(application_data) else raise(WebSocket::Error::Frame::InvalidPayloadEncoding) if frame_type == :text && !application_data.valid_encoding? + return @frame.class.new(version: @frame.version, type: frame_type, data: application_data, decoded: true) end end nil end - # Allow turning on or off masking + # Drafts 00-04 do not use payload masking. + # @return [Boolean] false def masking? false end @@ -210,9 +216,11 @@ def decode_continuation_frame(application_data, frame_type) def decode_finish_continuation_frame(application_data) raise(WebSocket::Error::Frame::UnexpectedContinuationFrame) unless @frame_type + @application_data_buffer << application_data # Test valid UTF-8 encoding raise(WebSocket::Error::Frame::InvalidPayloadEncoding) if @frame_type == :text && !@application_data_buffer.valid_encoding? + message = @frame.class.new(version: @frame.version, type: @frame_type, data: @application_data_buffer, decoded: true) @application_data_buffer = nil @frame_type = nil diff --git a/lib/websocket/frame/handler/handler04.rb b/lib/websocket/frame/handler/handler04.rb index 782a1a38..c95df39c 100644 --- a/lib/websocket/frame/handler/handler04.rb +++ b/lib/websocket/frame/handler/handler04.rb @@ -4,11 +4,13 @@ module WebSocket module Frame module Handler + # Frame encoder/decoder for hybi draft 04 of the protocol. class Handler04 < Handler03 private # The only difference between draft 03 framing and draft 04 framing is # that the MORE bit has been changed to a FIN bit + # @return [Boolean] true def fin true end diff --git a/lib/websocket/frame/handler/handler05.rb b/lib/websocket/frame/handler/handler05.rb index 8b7546e4..0a721f23 100644 --- a/lib/websocket/frame/handler/handler05.rb +++ b/lib/websocket/frame/handler/handler05.rb @@ -4,8 +4,10 @@ module WebSocket module Frame module Handler + # Frame encoder/decoder for hybi drafts 05-06 of the protocol. class Handler05 < Handler04 # Since handler 5 masking should be enabled by default + # @return [Boolean] true def masking? true end diff --git a/lib/websocket/frame/handler/handler07.rb b/lib/websocket/frame/handler/handler07.rb index 0e2cffb6..f9cdaeee 100644 --- a/lib/websocket/frame/handler/handler07.rb +++ b/lib/websocket/frame/handler/handler07.rb @@ -4,6 +4,9 @@ module WebSocket module Frame module Handler + # Frame encoder/decoder for hybi drafts 07-13 of the protocol and RFC 6455. + # Adds close frame status codes and validates that text frame payloads + # (including the concatenated result of continuation frames) are valid UTF-8. class Handler07 < Handler05 # Hash of frame names and it's opcodes FRAME_TYPES = { @@ -18,16 +21,22 @@ class Handler07 < Handler05 # Hash of frame opcodes and it's names FRAME_TYPES_INVERSE = FRAME_TYPES.invert.freeze + # @see WebSocket::Frame::Handler::Base#encode_frame + # @raise [WebSocket::Error::Frame::UnknownCloseCode] if a close frame carries an invalid status code def encode_frame if @frame.type == :close code = @frame.code || 1000 raise WebSocket::Error::Frame::UnknownCloseCode unless valid_code?(code) + @frame.data = Data.new([code].pack('n') + @frame.data.to_s) @frame.code = nil end super end + # @see WebSocket::Frame::Handler::Base#decode_frame + # @raise [WebSocket::Error::Frame::UnknownCloseCode] if a close frame carries an invalid status code + # @raise [WebSocket::Error::Frame::InvalidPayloadEncoding] if a close frame's message is not valid UTF-8 def decode_frame result = super if close_code?(result) @@ -41,18 +50,27 @@ def decode_frame private + # Check if the given close code is one of the codes reserved by the spec, or in the + # range available for application use (3000-4999). + # @param [Integer] code Close status code + # @return [Boolean] true if code is valid def valid_code?(code) [1000, 1001, 1002, 1003, 1007, 1008, 1009, 1010, 1011].include?(code) || (3000..4999).cover?(code) end + # @param [String, nil] data Data to validate + # @return [Boolean] true if data is nil or valid UTF-8 def valid_encoding?(data) return true if data.nil? + data.encode('UTF-8') true rescue StandardError false end + # @param [WebSocket::Frame::Incoming, nil] frame Decoded frame to check + # @return [Boolean] true if frame is a close frame carrying a status code def close_code?(frame) frame && frame.type == :close && !frame.data.empty? end diff --git a/lib/websocket/frame/handler/handler75.rb b/lib/websocket/frame/handler/handler75.rb index 1c18796a..3ca5deda 100644 --- a/lib/websocket/frame/handler/handler75.rb +++ b/lib/websocket/frame/handler/handler75.rb @@ -4,6 +4,9 @@ module WebSocket module Frame module Handler + # Frame encoder/decoder for the original hixie-75/hixie-76 drafts of the protocol. + # These early drafts only support UTF-8 text frames delimited by \x00...\xff, and + # a length-prefixed binary framing format (never used for closing in practice). class Handler75 < Base # @see WebSocket::Frame::Base#supported_frames def supported_frames @@ -14,7 +17,7 @@ def supported_frames def encode_frame case @frame.type when :close then "\xff\x00" - when :text then + when :text ary = ["\x00", @frame.data, "\xff"] ary.map { |s| s.encode('UTF-8', 'UTF-8', invalid: :replace) } ary.join @@ -24,7 +27,7 @@ def encode_frame # @see WebSocket::Frame::Handler::Base#decode_frame def decode_frame - return if @frame.data.size.zero? + return if @frame.data.empty? pointer = 0 frame_type = @frame.data.getbyte(pointer) @@ -36,6 +39,7 @@ def decode_frame loop do return unless @frame.data.getbyte(pointer) + b = @frame.data.getbyte(pointer) pointer += 1 b_v = b & 0x7F @@ -52,9 +56,7 @@ def decode_frame @frame.instance_variable_set '@data', @frame.data[(pointer + length)..-1] # If the /frame type/ is 0xFF and the /length/ was 0, then close - if length.zero? - @frame.class.new(version: @frame.version, type: :close, decoded: true) - end + @frame.class.new(version: @frame.version, type: :close, decoded: true) if length.zero? end else # If the high-order bit of the /frame type/ byte is _not_ set diff --git a/lib/websocket/frame/incoming/client.rb b/lib/websocket/frame/incoming/client.rb index a64ad2d7..34abfee9 100644 --- a/lib/websocket/frame/incoming/client.rb +++ b/lib/websocket/frame/incoming/client.rb @@ -3,11 +3,17 @@ module WebSocket module Frame class Incoming + # Frames received by a client from a server. class Client < Incoming + # Per RFC 6455, frames sent from a server to a client must never be masked. + # @return [Boolean] false def incoming_masking? false end + # Whether this client masks the frames it sends to the server, as required + # for masking-capable protocol drafts. + # @return [Boolean] def outgoing_masking? @handler.masking? end diff --git a/lib/websocket/frame/incoming/server.rb b/lib/websocket/frame/incoming/server.rb index 6cd8d771..87b93547 100644 --- a/lib/websocket/frame/incoming/server.rb +++ b/lib/websocket/frame/incoming/server.rb @@ -3,11 +3,17 @@ module WebSocket module Frame class Incoming + # Frames received by a server from a client. class Server < Incoming + # Whether the client is expected to mask the frames it sends, as required + # for masking-capable protocol drafts. + # @return [Boolean] def incoming_masking? @handler.masking? end + # Per RFC 6455, frames sent from a server to a client must never be masked. + # @return [Boolean] false def outgoing_masking? false end diff --git a/lib/websocket/frame/outgoing.rb b/lib/websocket/frame/outgoing.rb index d8fec136..84d341b7 100644 --- a/lib/websocket/frame/outgoing.rb +++ b/lib/websocket/frame/outgoing.rb @@ -27,6 +27,7 @@ def require_sending? # Return raw frame formatted for sending. def to_s raise WebSocket::Error::Frame::UnknownFrameType unless supported? + @handler.encode_frame end rescue_method :to_s diff --git a/lib/websocket/frame/outgoing/client.rb b/lib/websocket/frame/outgoing/client.rb index fad72b87..ecce508e 100644 --- a/lib/websocket/frame/outgoing/client.rb +++ b/lib/websocket/frame/outgoing/client.rb @@ -3,11 +3,17 @@ module WebSocket module Frame class Outgoing + # Frames sent by a client to a server. class Client < Outgoing + # Per RFC 6455, frames sent from a server to a client must never be masked. + # @return [Boolean] false def incoming_masking? false end + # Whether this client must mask the frames it sends to the server, as required + # for masking-capable protocol drafts. + # @return [Boolean] def outgoing_masking? @handler.masking? end diff --git a/lib/websocket/frame/outgoing/server.rb b/lib/websocket/frame/outgoing/server.rb index 9e9d5b23..5d3dc717 100644 --- a/lib/websocket/frame/outgoing/server.rb +++ b/lib/websocket/frame/outgoing/server.rb @@ -3,11 +3,17 @@ module WebSocket module Frame class Outgoing + # Frames sent by a server to a client. class Server < Outgoing + # Whether the client is expected to mask the frames it sends, as required + # for masking-capable protocol drafts. + # @return [Boolean] def incoming_masking? @handler.masking? end + # Per RFC 6455, frames sent from a server to a client must never be masked. + # @return [Boolean] false def outgoing_masking? false end diff --git a/lib/websocket/handshake/base.rb b/lib/websocket/handshake/base.rb index 274b8fc1..df563034 100644 --- a/lib/websocket/handshake/base.rb +++ b/lib/websocket/handshake/base.rb @@ -108,7 +108,7 @@ def error=(message) super end - HEADER = /^([^:]+):\s*(.+)$/ + HEADER = /^([^:]+):\s*(.+)$/.freeze # Parse data imported to handshake and sets state to finished if necessary. # @return [Boolean] True if finished parsing. False if not all data received yet. @@ -124,6 +124,7 @@ def parse_data lines.each do |line| h = HEADER.match(line) next unless h # Skip any invalid headers + key = h[1].strip.downcase val = h[2].strip # If the header is already set and refers to the websocket protocol, append the new value diff --git a/lib/websocket/handshake/client.rb b/lib/websocket/handshake/client.rb index d1ae0e00..058766da 100644 --- a/lib/websocket/handshake/client.rb +++ b/lib/websocket/handshake/client.rb @@ -114,7 +114,7 @@ def include_version end end - FIRST_LINE = %r{^HTTP\/1\.1 (\d{3})[\w\s]*$} + FIRST_LINE = %r{^HTTP/1\.1 (\d{3})[\w\s]*$}.freeze # Parse first line of Server response. # @param [String] line Line to parse @@ -122,6 +122,7 @@ def include_version def parse_first_line(line) line_parts = line.match(FIRST_LINE) raise WebSocket::Error::Handshake::InvalidHeader unless line_parts + status = line_parts[1] raise WebSocket::Error::Handshake::InvalidStatusCode unless status == '101' end diff --git a/lib/websocket/handshake/handler/client.rb b/lib/websocket/handshake/handler/client.rb index efc57bf1..b57235c4 100644 --- a/lib/websocket/handshake/handler/client.rb +++ b/lib/websocket/handshake/handler/client.rb @@ -3,13 +3,15 @@ module WebSocket module Handshake module Handler + # Shared behaviour for hybi-family client handshakes (drafts 01 and up): + # the GET request line, header assembly and sub-protocol negotiation. class Client < Base private # @see WebSocket::Handshake::Handler::Base#header_line def header_line path = @handshake.path - path += '?' + @handshake.query if @handshake.query + path += "?#{@handshake.query}" if @handshake.query "GET #{path} HTTP/1.1" end @@ -22,8 +24,10 @@ def handshake_keys # @return [Boolean] True if matching. False otherwise(appropriate error is set) def verify_protocol return true if supported_protocols.empty? + protos = provided_protocols & supported_protocols raise WebSocket::Error::Handshake::UnsupportedProtocol if protos.empty? + true end end diff --git a/lib/websocket/handshake/handler/client01.rb b/lib/websocket/handshake/handler/client01.rb index 96abe4b1..003cc4ef 100644 --- a/lib/websocket/handshake/handler/client01.rb +++ b/lib/websocket/handshake/handler/client01.rb @@ -5,6 +5,8 @@ module WebSocket module Handshake module Handler + # Client handshake for hybi drafts 01-03, which reuse the hixie-76 challenge/response + # but add a numeric Sec-WebSocket-Draft header. class Client01 < Client76 private diff --git a/lib/websocket/handshake/handler/client04.rb b/lib/websocket/handshake/handler/client04.rb index a30dc5d0..3d24b3db 100644 --- a/lib/websocket/handshake/handler/client04.rb +++ b/lib/websocket/handshake/handler/client04.rb @@ -5,6 +5,8 @@ module WebSocket module Handshake module Handler + # Client handshake for hybi drafts 04-10, which introduced the + # Sec-WebSocket-Key/Sec-WebSocket-Accept SHA1-based challenge. class Client04 < Client # @see WebSocket::Handshake::Base#valid? def valid? @@ -39,13 +41,14 @@ def key # Value of Sec-WebSocket-Accept that should be delivered back by server # @return [Sering] accept def accept - @accept ||= [Digest::SHA1.digest(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')].pack('m').strip + @accept ||= [Digest::SHA1.digest("#{key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11")].pack('m').strip end # Verify if received header Sec-WebSocket-Accept matches generated one. # @return [Boolean] True if accept is matching. False otherwise(appropriate error is set) def verify_accept raise WebSocket::Error::Handshake::InvalidAuthentication unless @handshake.headers['sec-websocket-accept'] == accept + true end diff --git a/lib/websocket/handshake/handler/client11.rb b/lib/websocket/handshake/handler/client11.rb index b6717faf..c4ea544c 100644 --- a/lib/websocket/handshake/handler/client11.rb +++ b/lib/websocket/handshake/handler/client11.rb @@ -3,6 +3,8 @@ module WebSocket module Handshake module Handler + # Client handshake for hybi drafts 11-17 and RFC 6455. Identical to draft 04 except + # the origin header is renamed from Sec-WebSocket-Origin to Origin. class Client11 < Client04 private diff --git a/lib/websocket/handshake/handler/client75.rb b/lib/websocket/handshake/handler/client75.rb index 1eacd68c..4d4a8a71 100644 --- a/lib/websocket/handshake/handler/client75.rb +++ b/lib/websocket/handshake/handler/client75.rb @@ -3,6 +3,7 @@ module WebSocket module Handshake module Handler + # Client handshake for the original hixie-75 draft, with no authentication challenge. class Client75 < Client # @see WebSocket::Handshake::Base#valid? def valid? diff --git a/lib/websocket/handshake/handler/client76.rb b/lib/websocket/handshake/handler/client76.rb index b94e550c..fe0bd99a 100644 --- a/lib/websocket/handshake/handler/client76.rb +++ b/lib/websocket/handshake/handler/client76.rb @@ -5,6 +5,8 @@ module WebSocket module Handshake module Handler + # Client handshake for the hixie-76 draft, which adds an MD5-based key + # challenge/response exchanged in the handshake body. class Client76 < Client75 # @see WebSocket::Handshake::Base#valid? def valid? @@ -53,6 +55,7 @@ def key3 # @return [String] challenge def challenge return @challenge if defined?(@challenge) + key1 && key2 sum = [@key1_number].pack('N*') + [@key2_number].pack('N*') + @@ -65,6 +68,7 @@ def challenge # @return [Boolena] True if challenge matches, false otherwise(sets appropriate error) def verify_challenge raise WebSocket::Error::Handshake::InvalidAuthentication unless @handshake.leftovers == challenge + true end diff --git a/lib/websocket/handshake/handler/server.rb b/lib/websocket/handshake/handler/server.rb index 9cb34b00..01c9b2a4 100644 --- a/lib/websocket/handshake/handler/server.rb +++ b/lib/websocket/handshake/handler/server.rb @@ -3,6 +3,9 @@ module WebSocket module Handshake module Handler + # Marker base class for hybi-family server handshakes (drafts 04 and up). + # Behaviour is fully provided by {Base}; concrete handlers only need + # protocol-version-specific overrides. class Server < Base end end diff --git a/lib/websocket/handshake/handler/server04.rb b/lib/websocket/handshake/handler/server04.rb index b0917d45..34b2ce63 100644 --- a/lib/websocket/handshake/handler/server04.rb +++ b/lib/websocket/handshake/handler/server04.rb @@ -5,6 +5,8 @@ module WebSocket module Handshake module Handler + # Server handshake for hybi drafts 04-17 and RFC 6455, responding with the + # SHA1-based Sec-WebSocket-Accept signature. class Server04 < Server # @see WebSocket::Handshake::Base#valid? def valid? @@ -31,12 +33,14 @@ def handshake_keys # @return [String] signature def signature return unless key + string_to_sign = "#{key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11" [Digest::SHA1.digest(string_to_sign)].pack('m').chomp end def verify_key raise WebSocket::Error::Handshake::InvalidAuthentication unless key + true end @@ -46,6 +50,7 @@ def key def protocol return [] unless @handshake.headers.key?('sec-websocket-protocol') + protos = @handshake.headers['sec-websocket-protocol'].split(/ *, */) & @handshake.protocols [['Sec-WebSocket-Protocol', protos.first]] end diff --git a/lib/websocket/handshake/handler/server75.rb b/lib/websocket/handshake/handler/server75.rb index df259a01..4dcf7618 100644 --- a/lib/websocket/handshake/handler/server75.rb +++ b/lib/websocket/handshake/handler/server75.rb @@ -3,6 +3,7 @@ module WebSocket module Handshake module Handler + # Server handshake for the original hixie-75 draft, with no authentication challenge. class Server75 < Server private @@ -31,6 +32,7 @@ def handshake_keys def protocol return [] unless @handshake.headers.key?(headers[:protocol].downcase) + proto = @handshake.headers[headers[:protocol].downcase] [[headers[:protocol], @handshake.protocols.include?(proto) ? proto : nil]] end diff --git a/lib/websocket/handshake/handler/server76.rb b/lib/websocket/handshake/handler/server76.rb index 0ec32f46..09e633e7 100644 --- a/lib/websocket/handshake/handler/server76.rb +++ b/lib/websocket/handshake/handler/server76.rb @@ -5,6 +5,8 @@ module WebSocket module Handshake module Handler + # Server handshake for the hixie-76 draft, which responds with an MD5-based + # digest of the client's key challenge. class Server76 < Server75 # @see WebSocket::Handshake::Base#valid? def valid? diff --git a/lib/websocket/handshake/server.rb b/lib/websocket/handshake/server.rb index 109ccaaa..f99ac53d 100644 --- a/lib/websocket/handshake/server.rb +++ b/lib/websocket/handshake/server.rb @@ -160,7 +160,7 @@ def include_version end end - PATH = %r{^(\w+) (\/[^\s]*) HTTP\/1\.1$} + PATH = %r{^(\w+) (/[^\s]*) HTTP/1\.1$}.freeze # Parse first line of Client response. # @param [String] line Line to parse @@ -168,6 +168,7 @@ def include_version def parse_first_line(line) line_parts = line.match(PATH) raise WebSocket::Error::Handshake::InvalidHeader unless line_parts + method = line_parts[1].strip raise WebSocket::Error::Handshake::GetRequestRequired unless method == 'GET' diff --git a/lib/websocket/version.rb b/lib/websocket/version.rb index b480dc44..e43db391 100644 --- a/lib/websocket/version.rb +++ b/lib/websocket/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module WebSocket - VERSION = '1.2.11'.freeze + VERSION = '1.2.11' end diff --git a/spec/error_spec.rb b/spec/error_spec.rb new file mode 100644 index 00000000..0325472d --- /dev/null +++ b/spec/error_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WebSocket::Error do + # Recursively collects every leaf error class nested under `mod` (i.e. every + # class that defines its own #message, as opposed to abstract intermediate + # classes like WebSocket::Error::Frame or WebSocket::Error::Handshake). + def self.leaf_error_classes(mod) + mod.constants(false).each_with_object([]) do |const_name, list| + const = mod.const_get(const_name) + next unless const.is_a?(Class) && const < described_class + + list << const if const.instance_methods(false).include?(:message) + list.concat(leaf_error_classes(const)) + end + end + + it 'is a RuntimeError' do + expect(described_class).to be < RuntimeError + end + + errors = leaf_error_classes(described_class) + + it 'found at least one leaf error class to verify' do + expect(errors).not_to be_empty + end + + errors.each do |klass| + describe klass do + subject(:error) { klass.new } + + it 'exposes a symbolic message describing the failure' do + expect(error.message).to be_a(Symbol) + end + end + end +end diff --git a/spec/frame/base_spec.rb b/spec/frame/base_spec.rb new file mode 100644 index 00000000..1a542907 --- /dev/null +++ b/spec/frame/base_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WebSocket::Frame::Base do + describe '#supported_frames' do + it 'is abstract and must be implemented by subclasses' do + frame = described_class.new + expect { frame.supported_frames }.to raise_error(NotImplementedError) + end + end + + describe '#include_version' do + { + 0 => WebSocket::Frame::Handler::Handler75, + 1 => WebSocket::Frame::Handler::Handler75, + 2 => WebSocket::Frame::Handler::Handler75, + 75 => WebSocket::Frame::Handler::Handler75, + 76 => WebSocket::Frame::Handler::Handler75, + 3 => WebSocket::Frame::Handler::Handler03, + 4 => WebSocket::Frame::Handler::Handler04, + 5 => WebSocket::Frame::Handler::Handler05, + 6 => WebSocket::Frame::Handler::Handler05, + 7 => WebSocket::Frame::Handler::Handler07, + 13 => WebSocket::Frame::Handler::Handler07 + }.each do |version, handler_class| + it "uses #{handler_class} for protocol version #{version}" do + frame = described_class.new(version: version) + expect(frame.instance_variable_get(:@handler)).to be_a(handler_class) + end + end + end +end diff --git a/spec/frame/data_spec.rb b/spec/frame/data_spec.rb new file mode 100644 index 00000000..c1875c0d --- /dev/null +++ b/spec/frame/data_spec.rb @@ -0,0 +1,45 @@ +# encoding: binary +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WebSocket::Frame::Data do + describe '#set_mask' do + it 'raises MaskTooShort when fewer than 4 bytes are available' do + data = described_class.new('ab') + expect { data.set_mask }.to raise_error(WebSocket::Error::Frame::MaskTooShort) + end + + it 'uses the first 4 bytes as masking key for subsequent unmasking' do + mask = [0x11, 0x22, 0x33, 0x44] + masked_payload = 'Hi'.bytes.each_with_index.map { |byte, i| byte ^ mask[i % 4] }.pack('C*') + data = described_class.new(mask.pack('C*') + masked_payload) + + data.set_mask + expect(data.getbytes(4, 2)).to eql('Hi') + end + + it 'leaves data unmasked when unset_mask is called' do + data = described_class.new('abcd') + data.set_mask + data.unset_mask + expect(data.getbytes(0, 4)).to eql('abcd') + end + end + + describe '#mask' do + let(:data) { described_class.new('') } + + it 'XORs each byte with the mask when no native implementation is available' do + expect(data.mask([0b1010, 0b0101], [0b1111, 0b1111])).to eql([0b0101, 0b1010]) + end + + it 'delegates to a native implementation when available' do + def data.mask_native(payload, mask) + [:native, payload, mask] + end + + expect(data.mask([1, 2], [3, 4])).to eql([:native, [1, 2], [3, 4]]) + end + end +end diff --git a/spec/frame/handler_base_spec.rb b/spec/frame/handler_base_spec.rb new file mode 100644 index 00000000..f22217f4 --- /dev/null +++ b/spec/frame/handler_base_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WebSocket::Frame::Handler::Base do + subject(:handler) { described_class.new(nil) } + + describe '#encode_frame' do + it 'is abstract and must be implemented by subclasses' do + expect { handler.encode_frame }.to raise_error(NotImplementedError) + end + end + + describe '#decode_frame' do + it 'is abstract and must be implemented by subclasses' do + expect { handler.decode_frame }.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/frame/incoming_03_spec.rb b/spec/frame/incoming_03_spec.rb index 78ee024a..24fb9ccb 100644 --- a/spec/frame/incoming_03_spec.rb +++ b/spec/frame/incoming_03_spec.rb @@ -16,7 +16,7 @@ it_behaves_like 'valid_incoming_frame' context 'should properly decode close frame' do - let(:encoded_text) { "\x01\x05" + decoded_text } + let(:encoded_text) { "\x01\x05#{decoded_text}" } let(:frame_type) { :close } let(:decoded_text) { 'Hello' } @@ -24,7 +24,7 @@ end context 'should properly decode ping frame' do - let(:encoded_text) { "\x02\x05" + decoded_text } + let(:encoded_text) { "\x02\x05#{decoded_text}" } let(:frame_type) { :ping } let(:decoded_text) { 'Hello' } @@ -32,7 +32,7 @@ end context 'should properly decode pong frame' do - let(:encoded_text) { "\x03\x05" + decoded_text } + let(:encoded_text) { "\x03\x05#{decoded_text}" } let(:frame_type) { :pong } let(:decoded_text) { 'Hello' } @@ -40,7 +40,7 @@ end context 'should properly decode text frame' do - let(:encoded_text) { "\x04\x05" + decoded_text } + let(:encoded_text) { "\x04\x05#{decoded_text}" } let(:decoded_text) { 'Hello' } let(:frame_type) { :text } @@ -72,7 +72,7 @@ end context 'should properly decode 256 bytes binary frame' do - let(:encoded_text) { "\x05\x7E\x01\x00" + decoded_text } + let(:encoded_text) { "\x05~\x01\x00#{decoded_text}" } let(:frame_type) { :binary } let(:decoded_text) { 'a' * 256 } @@ -80,7 +80,7 @@ end context 'should properly decode 64KiB binary frame' do - let(:encoded_text) { "\x05\x7F\x00\x00\x00\x00\x00\x01\x00\x00" + decoded_text } + let(:encoded_text) { "\x05\x7F\x00\x00\x00\x00\x00\x01\x00\x00#{decoded_text}" } let(:frame_type) { :binary } let(:decoded_text) { 'a' * 65_536 } @@ -103,7 +103,7 @@ end context 'should raise error with too long frame' do - let(:encoded_text) { "\x04\x7F" + 'a' * WebSocket.max_frame_size } + let(:encoded_text) { "\x04\x7F#{'a' * WebSocket.max_frame_size}" } let(:decoded_text) { nil } let(:error) { WebSocket::Error::Frame::TooLong } @@ -117,4 +117,66 @@ it_behaves_like 'valid_incoming_frame' end + + context 'should raise error with invalid UTF-8 in a standalone text frame' do + let(:encoded_text) { "\x04\x01\xFF" } + let(:decoded_text) { nil } + let(:error) { WebSocket::Error::Frame::InvalidPayloadEncoding } + + it_behaves_like 'valid_incoming_frame' + end + + context 'should raise error with invalid UTF-8 spanning a continuation frame' do + let(:encoded_text) { "\x84\x01\xE3\x00\x01\x28" } + let(:decoded_text) { nil } + let(:error) { WebSocket::Error::Frame::InvalidPayloadEncoding } + + it_behaves_like 'valid_incoming_frame' + end + + context 'should raise error when reserved bits are used' do + let(:encoded_text) { "\x14\x00" } + let(:decoded_text) { nil } + let(:error) { WebSocket::Error::Frame::ReservedBitUsed } + + it_behaves_like 'valid_incoming_frame' + end + + context 'should raise error with a fragmented control frame' do + let(:encoded_text) { "\x82\x00" } + let(:decoded_text) { nil } + let(:error) { WebSocket::Error::Frame::FragmentedControlFrame } + + it_behaves_like 'valid_incoming_frame' + end + + context 'should raise error with a data frame sent instead of a continuation' do + let(:encoded_text) { "\x84\x03Hel\x04\x02lo" } + let(:decoded_text) { nil } + let(:error) { WebSocket::Error::Frame::DataFrameInsteadContinuation } + + it_behaves_like 'valid_incoming_frame' + end + + context 'should raise error with an over-long control frame payload' do + let(:encoded_text) { "\x02\x7E" } + let(:decoded_text) { nil } + let(:error) { WebSocket::Error::Frame::ControlFramePayloadTooLong } + + it_behaves_like 'valid_incoming_frame' + end + + context 'should wait with incomplete extended 16-bit length header' do + let(:encoded_text) { "\x04\x7E\x00" } + let(:decoded_text) { nil } + + it_behaves_like 'valid_incoming_frame' + end + + context 'should wait with incomplete extended 64-bit length header' do + let(:encoded_text) { "\x04\x7F\x00\x00\x00" } + let(:decoded_text) { nil } + + it_behaves_like 'valid_incoming_frame' + end end diff --git a/spec/frame/incoming_04_spec.rb b/spec/frame/incoming_04_spec.rb index 781c5e5e..3aaeaf8b 100644 --- a/spec/frame/incoming_04_spec.rb +++ b/spec/frame/incoming_04_spec.rb @@ -16,7 +16,7 @@ it_behaves_like 'valid_incoming_frame' context 'should properly decode close frame' do - let(:encoded_text) { "\x81\x05" + decoded_text } + let(:encoded_text) { "\x81\x05#{decoded_text}" } let(:frame_type) { :close } let(:decoded_text) { 'Hello' } @@ -24,7 +24,7 @@ end context 'should properly decode ping frame' do - let(:encoded_text) { "\x82\x05" + decoded_text } + let(:encoded_text) { "\x82\x05#{decoded_text}" } let(:frame_type) { :ping } let(:decoded_text) { 'Hello' } @@ -32,7 +32,7 @@ end context 'should properly decode pong frame' do - let(:encoded_text) { "\x83\x05" + decoded_text } + let(:encoded_text) { "\x83\x05#{decoded_text}" } let(:frame_type) { :pong } let(:decoded_text) { 'Hello' } @@ -40,7 +40,7 @@ end context 'should properly decode text frame' do - let(:encoded_text) { "\x84\x05" + decoded_text } + let(:encoded_text) { "\x84\x05#{decoded_text}" } let(:decoded_text) { 'Hello' } let(:frame_type) { :text } @@ -72,7 +72,7 @@ end context 'should properly decode 256 bytes binary frame' do - let(:encoded_text) { "\x85\x7E\x01\x00" + decoded_text } + let(:encoded_text) { "\x85~\x01\x00#{decoded_text}" } let(:frame_type) { :binary } let(:decoded_text) { 'a' * 256 } @@ -80,7 +80,7 @@ end context 'should properly decode 64KiB binary frame' do - let(:encoded_text) { "\x85\x7F\x00\x00\x00\x00\x00\x01\x00\x00" + decoded_text } + let(:encoded_text) { "\x85\x7F\x00\x00\x00\x00\x00\x01\x00\x00#{decoded_text}" } let(:frame_type) { :binary } let(:decoded_text) { 'a' * 65_536 } @@ -103,7 +103,7 @@ end context 'should raise error with too long frame' do - let(:encoded_text) { "\x84\x7F" + 'a' * WebSocket.max_frame_size } + let(:encoded_text) { "\x84\x7F#{'a' * WebSocket.max_frame_size}" } let(:decoded_text) { nil } let(:error) { WebSocket::Error::Frame::TooLong } diff --git a/spec/frame/incoming_05_spec.rb b/spec/frame/incoming_05_spec.rb index 8aaa4322..4b7e7c00 100644 --- a/spec/frame/incoming_05_spec.rb +++ b/spec/frame/incoming_05_spec.rb @@ -16,7 +16,7 @@ it_behaves_like 'valid_incoming_frame' context 'should properly decode close frame' do - let(:encoded_text) { "\x81\x05" + decoded_text } + let(:encoded_text) { "\x81\x05#{decoded_text}" } let(:frame_type) { :close } let(:decoded_text) { 'Hello' } @@ -24,7 +24,7 @@ end context 'should properly decode ping frame' do - let(:encoded_text) { "\x82\x05" + decoded_text } + let(:encoded_text) { "\x82\x05#{decoded_text}" } let(:frame_type) { :ping } let(:decoded_text) { 'Hello' } @@ -32,7 +32,7 @@ end context 'should properly decode pong frame' do - let(:encoded_text) { "\x83\x05" + decoded_text } + let(:encoded_text) { "\x83\x05#{decoded_text}" } let(:frame_type) { :pong } let(:decoded_text) { 'Hello' } @@ -40,7 +40,7 @@ end context 'should properly decode text frame' do - let(:encoded_text) { "\x84\x05" + decoded_text } + let(:encoded_text) { "\x84\x05#{decoded_text}" } let(:decoded_text) { 'Hello' } let(:frame_type) { :text } @@ -88,7 +88,7 @@ end context 'should properly decode 256 bytes binary frame' do - let(:encoded_text) { "\x85\x7E\x01\x00" + decoded_text } + let(:encoded_text) { "\x85~\x01\x00#{decoded_text}" } let(:frame_type) { :binary } let(:decoded_text) { 'a' * 256 } @@ -96,7 +96,7 @@ end context 'should properly decode 64KiB binary frame' do - let(:encoded_text) { "\x85\x7F\x00\x00\x00\x00\x00\x01\x00\x00" + decoded_text } + let(:encoded_text) { "\x85\x7F\x00\x00\x00\x00\x00\x01\x00\x00#{decoded_text}" } let(:frame_type) { :binary } let(:decoded_text) { 'a' * 65_536 } @@ -119,7 +119,7 @@ end context 'should raise error with too long frame' do - let(:encoded_text) { "\x84\x7F" + 'a' * WebSocket.max_frame_size } + let(:encoded_text) { "\x84\x7F#{'a' * WebSocket.max_frame_size}" } let(:decoded_text) { nil } let(:error) { WebSocket::Error::Frame::TooLong } diff --git a/spec/frame/incoming_07_spec.rb b/spec/frame/incoming_07_spec.rb index 990fe09e..9b622e42 100644 --- a/spec/frame/incoming_07_spec.rb +++ b/spec/frame/incoming_07_spec.rb @@ -16,7 +16,7 @@ it_behaves_like 'valid_incoming_frame' context 'should properly decode close frame' do - let(:encoded_text) { "\x88\x07\x03\xE8" + decoded_text } + let(:encoded_text) { "\x88\a\x03\xE8#{decoded_text}" } let(:frame_type) { :close } let(:decoded_text) { 'Hello' } let(:close_code) { 1000 } @@ -41,7 +41,7 @@ end context 'should properly decode ping frame' do - let(:encoded_text) { "\x89\x05" + decoded_text } + let(:encoded_text) { "\x89\x05#{decoded_text}" } let(:frame_type) { :ping } let(:decoded_text) { 'Hello' } @@ -49,7 +49,7 @@ end context 'should properly decode pong frame' do - let(:encoded_text) { "\x8a\x05" + decoded_text } + let(:encoded_text) { "\x8A\x05#{decoded_text}" } let(:frame_type) { :pong } let(:decoded_text) { 'Hello' } @@ -105,7 +105,7 @@ end context 'should properly decode 256 bytes binary frame' do - let(:encoded_text) { "\x82\x7E\x01\x00" + decoded_text } + let(:encoded_text) { "\x82~\x01\x00#{decoded_text}" } let(:frame_type) { :binary } let(:decoded_text) { 'a' * 256 } @@ -113,7 +113,7 @@ end context 'should properly decode 64KiB binary frame' do - let(:encoded_text) { "\x82\x7F\x00\x00\x00\x00\x00\x01\x00\x00" + decoded_text } + let(:encoded_text) { "\x82\x7F\x00\x00\x00\x00\x00\x01\x00\x00#{decoded_text}" } let(:frame_type) { :binary } let(:decoded_text) { 'a' * 65_536 } @@ -136,7 +136,7 @@ end context 'should raise error with too long frame' do - let(:encoded_text) { "\x81\x7F" + 'a' * WebSocket.max_frame_size } + let(:encoded_text) { "\x81\x7F#{'a' * WebSocket.max_frame_size}" } let(:decoded_text) { nil } let(:error) { WebSocket::Error::Frame::TooLong } diff --git a/spec/frame/incoming_75_spec.rb b/spec/frame/incoming_75_spec.rb index 418616c3..b1d6d4cd 100644 --- a/spec/frame/incoming_75_spec.rb +++ b/spec/frame/incoming_75_spec.rb @@ -54,7 +54,38 @@ end context 'with too long frame' do - let(:encoded_text) { "\x00" + 'a' * WebSocket.max_frame_size + "\xFF" } + let(:encoded_text) { "\x00#{'a' * WebSocket.max_frame_size}\xFF" } + let(:error) { WebSocket::Error::Frame::TooLong } + + it_behaves_like 'valid_incoming_frame' + end + + context 'with an incomplete length-prefixed frame header' do + let(:encoded_text) { "\x80" } + let(:decoded_text) { nil } + + it_behaves_like 'valid_incoming_frame' + end + + context 'with an incomplete length-prefixed frame payload' do + let(:encoded_text) { "\x80\x05ab" } + let(:decoded_text) { nil } + + it_behaves_like 'valid_incoming_frame' + end + + context 'with a multi-byte length-prefixed frame' do + # length 129, encoded as the base-128 digits [1, 1] (0x81 continues, 0x01 ends) + let(:encoded_text) { "\x80\x81\x01#{'a' * 129}" } + let(:decoded_text) { nil } + + it_behaves_like 'valid_incoming_frame' + end + + context 'with a length-prefixed frame exceeding the maximum frame size' do + # length 200_000 > WebSocket.max_frame_size, base-128 encoded as [12, 26, 64] + let(:encoded_text) { "\xFF\x8C\x9A\x40" } + let(:decoded_text) { nil } let(:error) { WebSocket::Error::Frame::TooLong } it_behaves_like 'valid_incoming_frame' diff --git a/spec/frame/incoming_common_spec.rb b/spec/frame/incoming_common_spec.rb index edf5e9e5..eab172d9 100644 --- a/spec/frame/incoming_common_spec.rb +++ b/spec/frame/incoming_common_spec.rb @@ -29,4 +29,10 @@ expect(subject.error?).to be true expect(subject.error).to be :unknown_protocol_version end + + it 'does not attempt to decode an already-decoded frame' do + subject = WebSocket::Frame::Incoming.new(decoded: true) + expect(subject).to be_decoded + expect(subject.next).to be_nil + end end diff --git a/spec/frame/masking_direction_spec.rb b/spec/frame/masking_direction_spec.rb new file mode 100644 index 00000000..4e9c6f67 --- /dev/null +++ b/spec/frame/masking_direction_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Frame masking direction' do + describe WebSocket::Frame::Incoming::Client do + subject(:frame) { described_class.new(version: 13) } + + it 'never expects frames received from the server to be masked' do + expect(frame.incoming_masking?).to be false + end + + it 'masks frames sent to the server according to the handler' do + expect(frame.outgoing_masking?).to eql frame.instance_variable_get(:@handler).masking? + end + end + + describe WebSocket::Frame::Incoming::Server do + subject(:frame) { described_class.new(version: 13) } + + it 'expects frames received from the client to be masked according to the handler' do + expect(frame.incoming_masking?).to eql frame.instance_variable_get(:@handler).masking? + end + + it 'never masks frames sent to the client' do + expect(frame.outgoing_masking?).to be false + end + end + + describe WebSocket::Frame::Outgoing::Client do + subject(:frame) { described_class.new(version: 13) } + + it 'never expects frames received from the server to be masked' do + expect(frame.incoming_masking?).to be false + end + + it 'masks frames sent to the server according to the handler' do + expect(frame.outgoing_masking?).to eql frame.instance_variable_get(:@handler).masking? + end + end + + describe WebSocket::Frame::Outgoing::Server do + subject(:frame) { described_class.new(version: 13) } + + it 'expects frames received from the client to be masked according to the handler' do + expect(frame.incoming_masking?).to eql frame.instance_variable_get(:@handler).masking? + end + + it 'never masks frames sent to the client' do + expect(frame.outgoing_masking?).to be false + end + end +end diff --git a/spec/frame/outgoing_03_spec.rb b/spec/frame/outgoing_03_spec.rb index e8394a4e..ddfbaf22 100644 --- a/spec/frame/outgoing_03_spec.rb +++ b/spec/frame/outgoing_03_spec.rb @@ -19,7 +19,7 @@ context 'should properly encode close frame' do let(:frame_type) { :close } let(:decoded_text) { 'Hello' } - let(:encoded_text) { "\x01\x05" + decoded_text } + let(:encoded_text) { "\x01\x05#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -28,7 +28,7 @@ context 'should properly encode ping frame' do let(:frame_type) { :ping } let(:decoded_text) { 'Hello' } - let(:encoded_text) { "\x02\x05" + decoded_text } + let(:encoded_text) { "\x02\x05#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -37,7 +37,7 @@ context 'should properly encode pong frame' do let(:frame_type) { :pong } let(:decoded_text) { 'Hello' } - let(:encoded_text) { "\x03\x05" + decoded_text } + let(:encoded_text) { "\x03\x05#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -45,7 +45,7 @@ context 'should properly encode text frame' do let(:decoded_text) { 'Hello' } - let(:encoded_text) { "\x04\x05" + decoded_text } + let(:encoded_text) { "\x04\x05#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -54,7 +54,7 @@ context 'should properly encode 256 bytes binary frame' do let(:frame_type) { :binary } let(:decoded_text) { 'a' * 256 } - let(:encoded_text) { "\x05\x7E\x01\x00" + decoded_text } + let(:encoded_text) { "\x05~\x01\x00#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -63,7 +63,7 @@ context 'should properly encode 64KiB binary frame' do let(:frame_type) { :binary } let(:decoded_text) { 'a' * 65_536 } - let(:encoded_text) { "\x05\x7F\x00\x00\x00\x00\x00\x01\x00\x00" + decoded_text } + let(:encoded_text) { "\x05\x7F\x00\x00\x00\x00\x00\x01\x00\x00#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' diff --git a/spec/frame/outgoing_04_spec.rb b/spec/frame/outgoing_04_spec.rb index a1ef7182..834c4445 100644 --- a/spec/frame/outgoing_04_spec.rb +++ b/spec/frame/outgoing_04_spec.rb @@ -19,7 +19,7 @@ context 'should properly encode close frame' do let(:frame_type) { :close } let(:decoded_text) { 'Hello' } - let(:encoded_text) { "\x81\x05" + decoded_text } + let(:encoded_text) { "\x81\x05#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -28,7 +28,7 @@ context 'should properly encode ping frame' do let(:frame_type) { :ping } let(:decoded_text) { 'Hello' } - let(:encoded_text) { "\x82\x05" + decoded_text } + let(:encoded_text) { "\x82\x05#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -37,7 +37,7 @@ context 'should properly encode pong frame' do let(:frame_type) { :pong } let(:decoded_text) { 'Hello' } - let(:encoded_text) { "\x83\x05" + decoded_text } + let(:encoded_text) { "\x83\x05#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -45,7 +45,7 @@ context 'should properly encode text frame' do let(:decoded_text) { 'Hello' } - let(:encoded_text) { "\x84\x05" + decoded_text } + let(:encoded_text) { "\x84\x05#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -54,7 +54,7 @@ context 'should properly encode 256 bytes binary frame' do let(:frame_type) { :binary } let(:decoded_text) { 'a' * 256 } - let(:encoded_text) { "\x85\x7E\x01\x00" + decoded_text } + let(:encoded_text) { "\x85~\x01\x00#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -63,7 +63,7 @@ context 'should properly encode 64KiB binary frame' do let(:frame_type) { :binary } let(:decoded_text) { 'a' * 65_536 } - let(:encoded_text) { "\x85\x7F\x00\x00\x00\x00\x00\x01\x00\x00" + decoded_text } + let(:encoded_text) { "\x85\x7F\x00\x00\x00\x00\x00\x01\x00\x00#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' diff --git a/spec/frame/outgoing_05_spec.rb b/spec/frame/outgoing_05_spec.rb index cd26dd58..bb421da1 100644 --- a/spec/frame/outgoing_05_spec.rb +++ b/spec/frame/outgoing_05_spec.rb @@ -19,7 +19,7 @@ context 'should properly encode close frame' do let(:frame_type) { :close } let(:decoded_text) { 'Hello' } - let(:encoded_text) { "\x81\x05" + decoded_text } + let(:encoded_text) { "\x81\x05#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -28,7 +28,7 @@ context 'should properly encode ping frame' do let(:frame_type) { :ping } let(:decoded_text) { 'Hello' } - let(:encoded_text) { "\x82\x05" + decoded_text } + let(:encoded_text) { "\x82\x05#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -37,7 +37,7 @@ context 'should properly encode pong frame' do let(:frame_type) { :pong } let(:decoded_text) { 'Hello' } - let(:encoded_text) { "\x83\x05" + decoded_text } + let(:encoded_text) { "\x83\x05#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -45,7 +45,7 @@ context 'should properly encode text frame' do let(:decoded_text) { 'Hello' } - let(:encoded_text) { "\x84\x05" + decoded_text } + let(:encoded_text) { "\x84\x05#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -54,7 +54,7 @@ context 'should properly encode 256 bytes binary frame' do let(:frame_type) { :binary } let(:decoded_text) { 'a' * 256 } - let(:encoded_text) { "\x85\x7E\x01\x00" + decoded_text } + let(:encoded_text) { "\x85~\x01\x00#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -63,7 +63,7 @@ context 'should properly encode 64KiB binary frame' do let(:frame_type) { :binary } let(:decoded_text) { 'a' * 65_536 } - let(:encoded_text) { "\x85\x7F\x00\x00\x00\x00\x00\x01\x00\x00" + decoded_text } + let(:encoded_text) { "\x85\x7F\x00\x00\x00\x00\x00\x01\x00\x00#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' diff --git a/spec/frame/outgoing_07_spec.rb b/spec/frame/outgoing_07_spec.rb index 6441825a..d76c9491 100644 --- a/spec/frame/outgoing_07_spec.rb +++ b/spec/frame/outgoing_07_spec.rb @@ -20,7 +20,7 @@ context 'should properly encode close frame without close code' do let(:frame_type) { :close } let(:decoded_text) { 'Hello' } - let(:encoded_text) { "\x88\x07\x03\xE8" + decoded_text } + let(:encoded_text) { "\x88\a\x03\xE8#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -30,7 +30,7 @@ let(:frame_type) { :close } let(:decoded_text) { 'Hello' } let(:close_code) { 1001 } - let(:encoded_text) { "\x88\x07\x03\xE9" + decoded_text } + let(:encoded_text) { "\x88\a\x03\xE9#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -39,7 +39,7 @@ context 'should properly encode ping frame' do let(:frame_type) { :ping } let(:decoded_text) { 'Hello' } - let(:encoded_text) { "\x89\x05" + decoded_text } + let(:encoded_text) { "\x89\x05#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -48,7 +48,7 @@ context 'should properly encode pong frame' do let(:frame_type) { :pong } let(:decoded_text) { 'Hello' } - let(:encoded_text) { "\x8a\x05" + decoded_text } + let(:encoded_text) { "\x8A\x05#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -56,7 +56,7 @@ context 'should properly encode text frame' do let(:decoded_text) { 'Hello' } - let(:encoded_text) { "\x81\x05" + decoded_text } + let(:encoded_text) { "\x81\x05#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -65,7 +65,7 @@ context 'should properly encode 256 bytes binary frame' do let(:frame_type) { :binary } let(:decoded_text) { 'a' * 256 } - let(:encoded_text) { "\x82\x7E\x01\x00" + decoded_text } + let(:encoded_text) { "\x82~\x01\x00#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -74,7 +74,7 @@ context 'should properly encode 64KiB binary frame' do let(:frame_type) { :binary } let(:decoded_text) { 'a' * 65_536 } - let(:encoded_text) { "\x82\x7F\x00\x00\x00\x00\x00\x01\x00\x00" + decoded_text } + let(:encoded_text) { "\x82\x7F\x00\x00\x00\x00\x00\x01\x00\x00#{decoded_text}" } let(:require_sending) { true } it_behaves_like 'valid_outgoing_frame' @@ -89,4 +89,22 @@ it_behaves_like 'valid_outgoing_frame' end + + context 'should return error for an unknown close code' do + let(:frame_type) { :close } + let(:close_code) { 1 } + let(:decoded_text) { 'Hello' } + let(:encoded_text) { nil } + let(:error) { :unknown_close_code } + let(:require_sending) { false } + + it_behaves_like 'valid_outgoing_frame' + end + + describe '#valid_encoding? (internal helper)' do + it 'treats nil data as valid' do + handler = frame.instance_variable_get(:@handler) + expect(handler.send(:valid_encoding?, nil)).to be true + end + end end diff --git a/spec/frame/outgoing_75_spec.rb b/spec/frame/outgoing_75_spec.rb index a7e5f2c4..3f0f1d6d 100644 --- a/spec/frame/outgoing_75_spec.rb +++ b/spec/frame/outgoing_75_spec.rb @@ -42,4 +42,13 @@ it_behaves_like 'valid_outgoing_frame' end + + describe '#encode_frame (internal helper)' do + it 'raises for a frame type the handler does not know how to encode' do + handler = frame.instance_variable_get(:@handler) + frame.instance_variable_set(:@type, :ping) + + expect { handler.encode_frame }.to raise_error(WebSocket::Error::Frame::UnknownFrameType) + end + end end diff --git a/spec/handshake/base_spec.rb b/spec/handshake/base_spec.rb new file mode 100644 index 00000000..fa69446a --- /dev/null +++ b/spec/handshake/base_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WebSocket::Handshake::Base do + describe '#initialize' do + it 'duplicates initializer values so callers cannot mutate internal state' do + host = 'example.com'.dup + handshake = described_class.new(host: host) + host << '.evil' + + expect(handshake.host).to eql('example.com') + end + + it 'falls back to the original object when it cannot be duplicated' do + undupable = Class.new do + def dup + raise TypeError, "can't dup" + end + end.new + + expect { described_class.new(custom: undupable) }.not_to raise_error + end + end + + describe '#should_respond?' do + it 'is abstract and must be implemented by subclasses' do + expect { described_class.new.should_respond? }.to raise_error(NotImplementedError) + end + end + + describe '#to_s' do + it 'returns an empty string when no handler has been selected yet' do + expect(described_class.new.to_s).to eql('') + end + end + + describe 'secure handshakes' do + subject(:handshake) { WebSocket::Handshake::Client.new(host: 'example.com', secure: true) } + + it 'defaults to port 443' do + expect(handshake.default_port).to be(443) + end + + it 'builds a wss:// uri' do + expect(handshake.uri).to start_with('wss://') + end + end + + describe 'parsing headers' do + it 'skips lines that are not valid HTTP headers' do + request = <<-REQUEST +GET /demo HTTP/1.1\r +Upgrade: websocket\r +this is not a valid header line\r +Connection: Upgrade\r +Host: example.com\r +Sec-WebSocket-Version: 13\r +Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r +\r + REQUEST + + handshake = WebSocket::Handshake::Server.new + handshake << request + + expect(handshake).to be_valid + expect(handshake.headers).not_to have_key('this is not a valid header line') + end + end + + describe 'merging repeated protocol headers' do + it 'joins repeated Sec-WebSocket-Protocol header lines instead of overwriting them' do + request = <<-REQUEST +GET /demo HTTP/1.1\r +Upgrade: websocket\r +Connection: Upgrade\r +Host: example.com\r +Sec-WebSocket-Version: 13\r +Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r +Sec-WebSocket-Protocol: foo\r +Sec-WebSocket-Protocol: bar\r +\r + REQUEST + + handshake = WebSocket::Handshake::Server.new + handshake << request + + expect(handshake.headers['sec-websocket-protocol']).to eql('foo, bar') + end + end +end diff --git a/spec/handshake/client_04_spec.rb b/spec/handshake/client_04_spec.rb index a2b2f2b7..1a34cd47 100644 --- a/spec/handshake/client_04_spec.rb +++ b/spec/handshake/client_04_spec.rb @@ -20,6 +20,11 @@ expect(handshake.error).to be(:invalid_handshake_authentication) end + it 'omits the Sec-WebSocket-Origin header when no origin is given' do + handshake = WebSocket::Handshake::Client.new(uri: 'ws://example.com/demo', version: version) + expect(handshake.to_s).not_to include('Sec-WebSocket-Origin') + end + context 'protocol header specified' do let(:handshake) { WebSocket::Handshake::Client.new(uri: 'ws://example.com/demo', origin: 'http://example.com', version: version, protocols: protocols) } @@ -33,6 +38,10 @@ expect(handshake).to be_finished expect(handshake).to be_valid end + + it 'includes the requested protocols in the request' do + expect(handshake.to_s).to include("Sec-WebSocket-Protocol: binary\r\n") + end end context 'multiple protocols requested' do diff --git a/spec/handshake/client_75_spec.rb b/spec/handshake/client_75_spec.rb index 69e25abf..dff878bd 100644 --- a/spec/handshake/client_75_spec.rb +++ b/spec/handshake/client_75_spec.rb @@ -11,6 +11,11 @@ it_behaves_like 'all client drafts' + it 'omits the Origin header when no origin is given' do + handshake = WebSocket::Handshake::Client.new(uri: 'ws://example.com/demo', version: version) + expect(handshake.to_s).not_to include('Origin') + end + context 'protocol header specified' do let(:handshake) { WebSocket::Handshake::Client.new(uri: 'ws://example.com/demo', origin: 'http://example.com', version: version, protocols: %w[binary]) } @@ -22,6 +27,10 @@ expect(handshake).to be_finished expect(handshake).to be_valid end + + it 'includes the requested protocol in the request' do + expect(handshake.to_s).to include("WebSocket-Protocol: binary\r\n") + end end context 'unsupported' do diff --git a/spec/handshake/client_spec.rb b/spec/handshake/client_spec.rb new file mode 100644 index 00000000..84428b44 --- /dev/null +++ b/spec/handshake/client_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WebSocket::Handshake::Client do + describe '#initialize' do + it 'accepts a host directly, without a url or uri' do + handshake = described_class.new(host: 'example.com') + expect(handshake.host).to eql('example.com') + end + + it 'sets an error when no host, url or uri is given' do + handshake = described_class.new + expect(handshake.error).to be(:no_host_provided) + end + + it 'sets an error for an unrecognized protocol version' do + handshake = described_class.new(host: 'example.com', version: 99) + expect(handshake.error).to be(:unknown_protocol_version) + end + + it 'uses the hixie-76-style handler with an added Sec-WebSocket-Draft header for drafts 1-3' do + handshake = described_class.new(host: 'example.com', version: 2) + expect(handshake.handler).to be_a(WebSocket::Handshake::Handler::Client01) + expect(handshake.to_s).to include("Sec-WebSocket-Draft: 2\r\n") + end + end + + describe '#should_respond?' do + it 'is false, as clients never send a response after the handshake' do + handshake = described_class.new(host: 'example.com') + expect(handshake.should_respond?).to be false + end + end + + describe 'parsing the server response' do + it 'sets an error when the status line cannot be parsed' do + handshake = described_class.new(host: 'example.com') + handshake << "not a status line\r\n\r\n" + + expect(handshake).to be_finished + expect(handshake.error).to be(:invalid_header) + end + end +end diff --git a/spec/handshake/handler_base_spec.rb b/spec/handshake/handler_base_spec.rb new file mode 100644 index 00000000..7b3ac5a0 --- /dev/null +++ b/spec/handshake/handler_base_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WebSocket::Handshake::Handler::Base do + subject(:handler) { described_class.new(nil) } + + it 'is valid by default' do + expect(handler).to be_valid + end + + it 'renders a blank header line, no headers and a blank finishing line by default' do + expect(handler.to_s).to eql("\r\n\r\n") + end +end diff --git a/spec/handshake/handler_internals_spec.rb b/spec/handshake/handler_internals_spec.rb new file mode 100644 index 00000000..a864ee4e --- /dev/null +++ b/spec/handshake/handler_internals_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Handshake handler internal helpers' do + describe WebSocket::Handshake::Handler::Client76 do + it 'reserves one leftover line for the hixie-76 challenge response' do + handler = described_class.new(nil) + expect(handler.send(:reserved_leftover_lines)).to be(1) + end + end + + describe WebSocket::Handshake::Handler::Server76 do + it 'reserves one leftover line for the hixie-76 challenge response' do + handler = described_class.new(nil) + expect(handler.send(:reserved_leftover_lines)).to be(1) + end + end +end diff --git a/spec/handshake/server_04_spec.rb b/spec/handshake/server_04_spec.rb index 851e9000..35c571be 100644 --- a/spec/handshake/server_04_spec.rb +++ b/spec/handshake/server_04_spec.rb @@ -16,6 +16,7 @@ expect(handshake).to be_finished expect(handshake).not_to be_valid expect(handshake.error).to be(:invalid_handshake_authentication) + expect(handshake.to_s).to include("Sec-WebSocket-Accept: \r\n") end context 'protocol header specified' do diff --git a/spec/handshake/server_76_spec.rb b/spec/handshake/server_76_spec.rb index b87d5875..cd4b1b7a 100644 --- a/spec/handshake/server_76_spec.rb +++ b/spec/handshake/server_76_spec.rb @@ -46,6 +46,15 @@ expect(handshake.error).to be(:invalid_handshake_authentication) end + it 'disallows request with a key that overflows 32 bits' do + @request_params = { key1: '99999999999 9' } + handshake << client_request + + expect(handshake).to be_finished + expect(handshake).not_to be_valid + expect(handshake.error).to be(:invalid_handshake_authentication) + end + context 'protocol header specified' do let(:handshake) { WebSocket::Handshake::Server.new(protocols: %w[binary]) } diff --git a/spec/handshake/server_spec.rb b/spec/handshake/server_spec.rb new file mode 100644 index 00000000..5d3c4175 --- /dev/null +++ b/spec/handshake/server_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'stringio' + +RSpec.describe WebSocket::Handshake::Server do + describe '#should_respond?' do + it 'is true, as servers always answer a client handshake' do + expect(described_class.new.should_respond?).to be true + end + end + + describe 'parsing the client request' do + it 'sets an error for an unrecognized protocol version' do + handshake = described_class.new + handshake << "GET /demo HTTP/1.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" \ + "Host: example.com\r\nSec-WebSocket-Version: 999\r\n\r\n" + + expect(handshake).to be_finished + expect(handshake.error).to be(:unknown_protocol_version) + end + + it 'sets an error when the request line cannot be parsed' do + handshake = described_class.new + handshake << "not a request line\r\n\r\n" + + expect(handshake).to be_finished + expect(handshake.error).to be(:invalid_header) + end + + it 'falls back to the legacy Sec-WebSocket-Draft header when Sec-WebSocket-Version is absent' do + handshake = described_class.new + handshake << "GET /demo HTTP/1.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" \ + "Host: example.com\r\nSec-WebSocket-Draft: 4\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" + + expect(handshake.version).to be(4) + end + end + + describe '#from_rack' do + let(:base_env) do + { + 'REQUEST_PATH' => '/demo', + 'QUERY_STRING' => '', + 'HTTP_SEC_WEBSOCKET_KEY1' => '4 @1 46546xW%0l 1 5', + 'HTTP_SEC_WEBSOCKET_KEY2' => '12998 5 Y3 1 .P00' + } + end + + it 'reads the body via #readpartial when available' do + handshake = described_class.new + handshake.from_rack(base_env.merge('rack.input' => StringIO.new('body-via-readpartial'))) + + expect(handshake.instance_variable_get(:@leftovers)).to eql('body-via-readpartial') + end + + it 'falls back to #read when #readpartial is unavailable' do + input = Class.new do + def read + 'body-via-read' + end + end.new + handshake = described_class.new + handshake.from_rack(base_env.merge('rack.input' => input)) + + expect(handshake.instance_variable_get(:@leftovers)).to eql('body-via-read') + end + + it 'falls back to #to_s when neither #readpartial nor #read is available' do + input = Class.new do + def to_s + 'body-via-to_s' + end + end.new + handshake = described_class.new + handshake.from_rack(base_env.merge('rack.input' => input)) + + expect(handshake.instance_variable_get(:@leftovers)).to eql('body-via-to_s') + end + end +end diff --git a/spec/nice_inspect_spec.rb b/spec/nice_inspect_spec.rb new file mode 100644 index 00000000..a8c50bfe --- /dev/null +++ b/spec/nice_inspect_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WebSocket::NiceInspect do + subject(:instance) { klass.new } + + let(:klass) do + Class.new do + include WebSocket::NiceInspect + + def initialize + @foo = 'bar' + end + end + end + + it 'includes the class name and object id' do + expect(instance.inspect).to match(/\A<#{Regexp.escape(klass.to_s)}:0x\h+ @foo="bar">\z/) + end + + it 'includes each instance variable and its inspected value' do + expect(instance.inspect).to include('@foo="bar"') + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 1fb8ecb3..fe401aa3 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,9 +1,21 @@ # frozen_string_literal: true +begin + require 'simplecov' + SimpleCov.start do + skip '/spec/' + enable_coverage :branch + minimum_coverage line: 100, branch: 100 + end +rescue LoadError + # simplecov requires Ruby >= 3.2 and isn't installed on older Rubies + # (see Gemfile) — the suite still runs, just without coverage enforcement. +end + require 'rspec' require 'websocket' -Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } +Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].sort.each { |f| require f } RSpec.configure do |config| config.disable_monkey_patching! diff --git a/spec/support/all_server_drafts.rb b/spec/support/all_server_drafts.rb index dea93b9c..67a79969 100644 --- a/spec/support/all_server_drafts.rb +++ b/spec/support/all_server_drafts.rb @@ -97,19 +97,19 @@ def validate_request it 'parses a rack request' do request = WEBrick::HTTPRequest.new(ServerSoftware: 'rspec') expect(request.parse(StringIO.new(client_request))).to be true - rest = client_request.slice((request.to_s.length..-1)) + rest = client_request.slice(request.to_s.length..-1) handshake.from_rack(request.meta_vars.merge( 'rack.input' => StringIO.new(rest), :random_key => :random_value - )) + )) validate_request end it 'parses a hash request' do request = WEBrick::HTTPRequest.new(ServerSoftware: 'rspec') expect(request.parse(StringIO.new(client_request))).to be true - body = client_request.slice((request.to_s.length..-1)) + body = client_request.slice(request.to_s.length..-1) path = request.path query = request.query_string diff --git a/spec/support/incoming_frames.rb b/spec/support/incoming_frames.rb index 3f3ec8ad..a65674ab 100644 --- a/spec/support/incoming_frames.rb +++ b/spec/support/incoming_frames.rb @@ -13,7 +13,7 @@ end it 'does not have type set' do - expect(subject.type).to be nil + expect(subject.type).to be_nil end it 'is not decoded' do diff --git a/spec/websocket_spec.rb b/spec/websocket_spec.rb new file mode 100644 index 00000000..319a2221 --- /dev/null +++ b/spec/websocket_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WebSocket do + describe '.max_frame_size' do + around do |example| + original = described_class.max_frame_size + example.run + described_class.max_frame_size = original + end + + it 'defaults to 20MB' do + described_class.instance_variable_set(:@max_frame_size, nil) + expect(described_class.max_frame_size).to eql(20 * 1024 * 1024) + end + + it 'can be reconfigured' do + described_class.max_frame_size = 1024 + expect(described_class.max_frame_size).to be(1024) + end + end + + describe '.should_raise' do + around do |example| + original = described_class.should_raise + example.run + described_class.should_raise = original + end + + it 'defaults to false' do + described_class.instance_variable_set(:@should_raise, nil) + expect(described_class.should_raise).to be false + end + + it 'can be reconfigured' do + described_class.should_raise = true + expect(described_class.should_raise).to be true + end + end + + describe '.load_native_extension' do + it 'silently ignores a missing websocket-native gem' do + allow(described_class).to receive(:require) + .with('websocket-native') + .and_raise(LoadError, 'cannot load such file -- websocket-native') + + expect { described_class.load_native_extension }.not_to raise_error + end + + it 're-raises load errors unrelated to websocket-native' do + allow(described_class).to receive(:require) + .with('websocket-native') + .and_raise(LoadError, 'cannot load such file -- some_other_gem') + + expect { described_class.load_native_extension }.to raise_error(LoadError, /some_other_gem/) + end + end +end diff --git a/websocket.gemspec b/websocket.gemspec index 5cbc1a2d..7aa0df22 100644 --- a/websocket.gemspec +++ b/websocket.gemspec @@ -1,6 +1,6 @@ # frozen_string_literal: true -$LOAD_PATH.push File.expand_path('../lib', __FILE__) +$LOAD_PATH.push File.expand_path('lib', __dir__) require 'websocket/version' Gem::Specification.new do |s| @@ -18,5 +18,5 @@ Gem::Specification.new do |s| s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } s.require_paths = ['lib'] - s.required_ruby_version = '>= 2.0' + s.required_ruby_version = '>= 2.1' end