lib/protocol/http2/frame.rb



# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2019-2024, by Samuel Williams.
# Copyright, 2019, by Yuta Iwama.

require_relative "error"

module Protocol
	module HTTP2
		END_STREAM = 0x1
		END_HEADERS = 0x4
		PADDED = 0x8
		PRIORITY = 0x20
		
		MAXIMUM_ALLOWED_WINDOW_SIZE = 0x7FFFFFFF
		MINIMUM_ALLOWED_FRAME_SIZE = 0x4000
		MAXIMUM_ALLOWED_FRAME_SIZE = 0xFFFFFF
		
		class Frame
			include Comparable
			
			# Stream Identifier cannot be bigger than this:
			# https://http2.github.stream/http2-spec/#rfc.section.4.1
			VALID_STREAM_ID = 0..0x7fffffff
			
			# The absolute maximum bounds for the length field:
			VALID_LENGTH = 0..0xffffff
			
			# Used for generating 24-bit frame length:
			LENGTH_HISHIFT = 16
			LENGTH_LOMASK  = 0xFFFF
			
			# The base class does not have any specific type index:
			TYPE = nil
			
			# @param length [Integer] the length of the payload, or nil if the header has not been read yet.
			def initialize(stream_id = 0, flags = 0, type = self.class::TYPE, length = nil, payload = nil)
				@stream_id = stream_id
				@flags = flags
				@type = type
				@length = length
				@payload = payload
			end
			
			def valid_type?
				@type == self.class::TYPE
			end
			
			def <=> other
				to_ary <=> other.to_ary
			end
			
			def to_ary
				[@length, @type, @flags, @stream_id, @payload]
			end
			
			# The generic frame header uses the following binary representation:
			#
			# +-----------------------------------------------+
			# |                 Length (24)                   |
			# +---------------+---------------+---------------+
			# |   Type (8)    |   Flags (8)   |
			# +-+-------------+---------------+-------------------------------+
			# |R|                 Stream Identifier (31)                      |
			# +=+=============================================================+
			# |                   Frame Payload (0...)                      ...
			# +---------------------------------------------------------------+
			
			attr_accessor :length
			attr_accessor :type
			attr_accessor :flags
			attr_accessor :stream_id
			attr_accessor :payload
			
			def unpack
				@payload
			end
			
			def pack(payload, maximum_size: nil)
				@payload = payload
				@length = payload.bytesize
				
				if maximum_size and @length > maximum_size
					raise ProtocolError, "Frame length bigger than maximum allowed: #{@length} > #{maximum_size}"
				end
			end
			
			def set_flags(mask)
				@flags |= mask
			end
			
			def clear_flags(mask)
				@flags &= ~mask
			end
			
			def flag_set?(mask)
				@flags & mask != 0
			end
			
			# Check if frame is a connection frame: SETTINGS, PING, GOAWAY, and any
			# frame addressed to stream ID = 0.
			#
			# @return [Boolean]
			def connection?
				@stream_id.zero?
			end
			
			HEADER_FORMAT = "CnCCN".freeze
			STREAM_ID_MASK  = 0x7fffffff
			
			# Generates common 9-byte frame header.
			# - http://tools.ietf.org/html/draft-ietf-httpbis-http2-16#section-4.1
			#
			# @return [String]
			def header
				unless VALID_LENGTH.include? @length
					raise ProtocolError, "Invalid frame length: #{@length.inspect}"
				end
				
				unless VALID_STREAM_ID.include? @stream_id
					raise ProtocolError, "Invalid stream identifier: #{@stream_id.inspect}"
				end
				
				[
					# These are guaranteed correct due to the length check above.
					@length >> LENGTH_HISHIFT,
					@length & LENGTH_LOMASK,
					@type,
					@flags,
					@stream_id
				].pack(HEADER_FORMAT)
			end
			
			# Decodes common 9-byte header.
			#
			# @param buffer [String]
			def self.parse_header(buffer)
				length_hi, length_lo, type, flags, stream_id = buffer.unpack(HEADER_FORMAT)
				length = (length_hi << LENGTH_HISHIFT) | length_lo
				stream_id = stream_id & STREAM_ID_MASK
				
				# puts "parse_header: length=#{length} type=#{type} flags=#{flags} stream_id=#{stream_id}"
				
				return length, type, flags, stream_id
			end
			
			def read_header(stream)
				if buffer = stream.read(9) and buffer.bytesize == 9
					@length, @type, @flags, @stream_id = Frame.parse_header(buffer)
					# puts "read_header: #{@length} #{@type} #{@flags} #{@stream_id}"
				else
					raise EOFError, "Could not read frame header!"
				end
			end
			
			def read_payload(stream)
				if payload = stream.read(@length) and payload.bytesize == @length
					@payload = payload
				else
					raise EOFError, "Could not read frame payload!"
				end
			end
			
			def read(stream, maximum_frame_size = MAXIMUM_ALLOWED_FRAME_SIZE)
				read_header(stream) unless @length
				
				if @length > maximum_frame_size
					raise FrameSizeError, "#{self.class} (type=#{@type}) frame length #{@length} exceeds maximum frame size #{maximum_frame_size}!"
				end
				
				read_payload(stream)
			end
			
			def write_header(stream)
				stream.write self.header
			end
			
			def write_payload(stream)
				stream.write(@payload) if @payload
			end
			
			def write(stream)
				# Validate the payload size:
				if @payload.nil?
					if @length != 0
						raise ProtocolError, "Invalid frame length: #{@length} != 0"
					end
				else
					if @length != @payload.bytesize
						raise ProtocolError, "Invalid payload size: #{@length} != #{@payload.bytesize}"
					end
				end
				
				self.write_header(stream)
				self.write_payload(stream)
			end
			
			def apply(connection)
				connection.receive_frame(self)
			end
			
			def inspect
				"\#<#{self.class} stream_id=#{@stream_id} flags=#{@flags} payload=#{self.unpack}>"
			end
		end
	end
end