pax_global_header00006660000000000000000000000064150676172470014531gustar00rootroot0000000000000052 comment=5b2149ccfd938f3e8d91a9b9f182e790fc5ae92b protocol-http1-0.35.2/000077500000000000000000000000001506761724700145175ustar00rootroot00000000000000protocol-http1-0.35.2/.editorconfig000066400000000000000000000001511506761724700171710ustar00rootroot00000000000000root = true [*] indent_style = tab indent_size = 2 [*.{yml,yaml}] indent_style = space indent_size = 2 protocol-http1-0.35.2/.github/000077500000000000000000000000001506761724700160575ustar00rootroot00000000000000protocol-http1-0.35.2/.github/copilot-instructions.md000066400000000000000000000016431506761724700226200ustar00rootroot00000000000000# GitHub Copilot Instructions ## Use Agent Context When working on this project, consult the `agents.md` file for project-specific guidelines, architecture decisions, and development patterns. This file contains curated information that will help you make better decisions aligned with the project's goals and standards. If the file does not exist, you will need to install it, by running the following command: ```bash $ bundle install $ bundle exec bake agent:context:install ``` This command will set up the necessary context files that help you understand the project structure, dependencies, and conventions. ## Ignoring Files The `.gitignore` file is split into two sections, separated by a blank line. The first section is automatically generated, while the second section is user controlled. While working on pull requests, you should not add unrelated changes to the `.gitignore` file as part of the pull request. protocol-http1-0.35.2/.github/workflows/000077500000000000000000000000001506761724700201145ustar00rootroot00000000000000protocol-http1-0.35.2/.github/workflows/documentation-coverage.yaml000066400000000000000000000006511506761724700254440ustar00rootroot00000000000000name: Documentation Coverage on: [push, pull_request] permissions: contents: read env: COVERAGE: PartialSummary jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ruby bundler-cache: true - name: Validate coverage timeout-minutes: 5 run: bundle exec bake decode:index:coverage lib protocol-http1-0.35.2/.github/workflows/documentation.yaml000066400000000000000000000021311506761724700236460ustar00rootroot00000000000000name: Documentation on: push: branches: - main # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages: permissions: contents: read pages: write id-token: write # Allow one concurrent deployment: concurrency: group: "pages" cancel-in-progress: true env: BUNDLE_WITH: maintenance jobs: generate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ruby bundler-cache: true - name: Installing packages run: sudo apt-get install wget - name: Generate documentation timeout-minutes: 5 run: bundle exec bake utopia:project:static --force no - name: Upload documentation artifact uses: actions/upload-pages-artifact@v3 with: path: docs deploy: runs-on: ubuntu-latest environment: name: github-pages url: ${{steps.deployment.outputs.page_url}} needs: generate steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 protocol-http1-0.35.2/.github/workflows/rubocop.yaml000066400000000000000000000005321506761724700224510ustar00rootroot00000000000000name: RuboCop on: [push, pull_request] permissions: contents: read jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ruby bundler-cache: true - name: Run RuboCop timeout-minutes: 10 run: bundle exec rubocop protocol-http1-0.35.2/.github/workflows/test-coverage.yaml000066400000000000000000000022031506761724700235450ustar00rootroot00000000000000name: Test Coverage on: [push, pull_request] permissions: contents: read env: COVERAGE: PartialSummary jobs: test: name: ${{matrix.ruby}} on ${{matrix.os}} runs-on: ${{matrix.os}}-latest strategy: matrix: os: - ubuntu - macos ruby: - ruby steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{matrix.ruby}} bundler-cache: true - name: Run tests timeout-minutes: 5 run: bundle exec bake test - uses: actions/upload-artifact@v4 with: include-hidden-files: true if-no-files-found: error name: coverage-${{matrix.os}}-${{matrix.ruby}} path: .covered.db validate: needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ruby bundler-cache: true - uses: actions/download-artifact@v4 - name: Validate coverage timeout-minutes: 5 run: bundle exec bake covered:validate --paths */.covered.db \; protocol-http1-0.35.2/.github/workflows/test-external.yaml000066400000000000000000000011101506761724700235700ustar00rootroot00000000000000name: Test External on: [push, pull_request] permissions: contents: read jobs: test: name: ${{matrix.ruby}} on ${{matrix.os}} runs-on: ${{matrix.os}}-latest strategy: matrix: os: - ubuntu - macos ruby: - "3.2" - "3.3" - "3.4" steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{matrix.ruby}} bundler-cache: true - name: Run tests timeout-minutes: 10 run: bundle exec bake test:external protocol-http1-0.35.2/.github/workflows/test.yaml000066400000000000000000000016261506761724700217640ustar00rootroot00000000000000name: Test on: [push, pull_request] permissions: contents: read jobs: test: name: ${{matrix.ruby}} on ${{matrix.os}} runs-on: ${{matrix.os}}-latest continue-on-error: ${{matrix.experimental}} strategy: matrix: os: - ubuntu - macos ruby: - "3.2" - "3.3" - "3.4" experimental: [false] include: - os: ubuntu ruby: truffleruby experimental: true - os: ubuntu ruby: jruby experimental: true - os: ubuntu ruby: head experimental: true steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{matrix.ruby}} bundler-cache: true - name: Run tests timeout-minutes: 10 run: bundle exec bake test protocol-http1-0.35.2/.gitignore000066400000000000000000000001351506761724700165060ustar00rootroot00000000000000/agents.md /.context /.bundle /pkg /gems.locked /.covered.db /external /fuzz/request/output protocol-http1-0.35.2/.mailmap000066400000000000000000000000351506761724700161360ustar00rootroot00000000000000Thomas Morgan protocol-http1-0.35.2/.rubocop.yml000066400000000000000000000022741506761724700167760ustar00rootroot00000000000000plugins: - rubocop-md - rubocop-socketry AllCops: DisabledByDefault: true Layout/ConsistentBlankLineIndentation: Enabled: true Layout/IndentationStyle: Enabled: true EnforcedStyle: tabs Layout/InitialIndentation: Enabled: true Layout/IndentationWidth: Enabled: true Width: 1 Layout/IndentationConsistency: Enabled: true EnforcedStyle: normal Layout/BlockAlignment: Enabled: true Layout/EndAlignment: Enabled: true EnforcedStyleAlignWith: start_of_line Layout/BeginEndAlignment: Enabled: true EnforcedStyleAlignWith: start_of_line Layout/ElseAlignment: Enabled: true Layout/DefEndAlignment: Enabled: true Layout/CaseIndentation: Enabled: true Layout/CommentIndentation: Enabled: true Layout/EmptyLinesAroundClassBody: Enabled: true Layout/EmptyLinesAroundModuleBody: Enabled: true Layout/EmptyLineAfterMagicComment: Enabled: true Layout/SpaceInsideBlockBraces: Enabled: true EnforcedStyle: no_space SpaceBeforeBlockParameters: false Layout/SpaceAroundBlockParameters: Enabled: true EnforcedStyleInsidePipes: no_space Style/FrozenStringLiteralComment: Enabled: true Style/StringLiterals: Enabled: true EnforcedStyle: double_quotes protocol-http1-0.35.2/bake.rb000066400000000000000000000005471506761724700157540ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. # Update the project documentation with the new version number. # # @parameter version [String] The new version number. def after_gem_release_version_increment(version) context["releases:update"].call(version) context["utopia:project:update"].call end protocol-http1-0.35.2/config/000077500000000000000000000000001506761724700157645ustar00rootroot00000000000000protocol-http1-0.35.2/config/external.yaml000066400000000000000000000001371506761724700204730ustar00rootroot00000000000000async-http: url: https://github.com/socketry/async-http.git command: bundle exec bake test protocol-http1-0.35.2/config/sus.rb000066400000000000000000000003331506761724700171220ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. ENV["TRACES_BACKEND"] ||= "traces/backend/test" require "traces" require "covered/sus" include Covered::Sus protocol-http1-0.35.2/config/traces.rb000066400000000000000000000002431506761724700175710ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. def prepare require "traces/provider/protocol/http1" end protocol-http1-0.35.2/context/000077500000000000000000000000001506761724700162035ustar00rootroot00000000000000protocol-http1-0.35.2/context/getting-started.md000066400000000000000000000064001506761724700216320ustar00rootroot00000000000000# Getting Started This guide explains how to get started with `protocol-http1`, a low-level implementation of the HTTP/1 protocol for building HTTP clients and servers. ## Installation Add the gem to your project: ```bash $ bundle add protocol-http1 ``` ## Core Concepts `protocol-http1` provides a low-level implementation of the HTTP/1 protocol with several core concepts: - A {ruby Protocol::HTTP1::Connection} which represents the main entry point for creating HTTP/1.1 clients and servers. - Integration with the `Protocol::HTTP::Body` classes for handling request and response bodies. ## Usage `protocol-http1` can be used to build both HTTP clients and servers. ### HTTP Server Here's a simple HTTP/1.1 server that responds to all requests with "Hello World": ```ruby #!/usr/bin/env ruby require "socket" require "protocol/http1/connection" require "protocol/http/body/buffered" # Test with: curl http://localhost:8080/ Addrinfo.tcp("0.0.0.0", 8080).listen do |server| loop do client, address = server.accept connection = Protocol::HTTP1::Connection.new(client) # Read request: while request = connection.read_request authority, method, path, version, headers, body = request # Write response: connection.write_response(version, 200, [["content-type", "text/plain"]]) connection.write_body(version, Protocol::HTTP::Body::Buffered.wrap(["Hello World"])) break unless connection.persistent end end end ``` The server: 1. Creates a new {ruby Protocol::HTTP1::Connection} for each client connection. 2. Reads incoming requests using `read_request`. 3. Sends responses using `write_response` and `write_body`. 4. Supports persistent connections by checking `connection.persistent`. ### HTTP Client Here's a simple HTTP/1.1 client that makes multiple requests: ```ruby #!/usr/bin/env ruby require "async" require "async/http/endpoint" require "protocol/http1/connection" Async do endpoint = Async::HTTP::Endpoint.parse("http://localhost:8080") peer = endpoint.connect puts "Connected to #{peer} #{peer.remote_address.inspect}" # IO Buffering... client = Protocol::HTTP1::Connection.new(peer) puts "Writing request..." 3.times do client.write_request("localhost", "GET", "/", "HTTP/1.1", [["Accept", "*/*"]]) client.write_body("HTTP/1.1", nil) puts "Reading response..." response = client.read_response("GET") version, status, reason, headers, body = response puts "Got response: #{response.inspect}" puts body&.read end puts "Closing client..." client.close end ``` The client: 1. Creates a connection to a server using `Async::HTTP::Endpoint`. 2. Creates a {ruby Protocol::HTTP1::Connection} wrapper around the socket. 3. Sends requests using `write_request` and `write_body`. 4. Reads responses using `read_response`. 5. Properly closes the connection when done. ### Connection Management The {ruby Protocol::HTTP1::Connection} handles: - **Request/Response Parsing**: Automatically parses HTTP/1.1 request and response formats. - **Persistent Connections**: Supports HTTP/1.1 keep-alive for multiple requests over one connection. - **Body Handling**: Integrates with `Protocol::HTTP::Body` classes for streaming and buffered content. - **Header Management**: Properly handles HTTP headers as arrays of key-value pairs. protocol-http1-0.35.2/context/index.yaml000066400000000000000000000011771506761724700202040ustar00rootroot00000000000000# Automatically generated context index for Utopia::Project guides. # Do not edit then files in this directory directly, instead edit the guides and then run `bake utopia:project:agent:context:update`. --- description: A low level implementation of the HTTP/1 protocol. metadata: documentation_uri: https://socketry.github.io/protocol-http1/ source_code_uri: https://github.com/socketry/protocol-http1.git files: - path: getting-started.md title: Getting Started description: This guide explains how to get started with `protocol-http1`, a low-level implementation of the HTTP/1 protocol for building HTTP clients and servers. protocol-http1-0.35.2/examples/000077500000000000000000000000001506761724700163355ustar00rootroot00000000000000protocol-http1-0.35.2/examples/http1/000077500000000000000000000000001506761724700173755ustar00rootroot00000000000000protocol-http1-0.35.2/examples/http1/client.rb000077500000000000000000000016331506761724700212060ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) require "async" require "async/http/endpoint" require "protocol/http1/connection" Async do endpoint = Async::HTTP::Endpoint.parse("http://localhost:8080") peer = endpoint.connect puts "Connected to #{peer} #{peer.remote_address.inspect}" # IO Buffering... client = Protocol::HTTP1::Connection.new(peer) puts "Writing request..." 3.times do client.write_request("localhost", "GET", "/", "HTTP/1.1", [["Accept", "*/*"]]) client.write_body("HTTP/1.1", nil) puts "Reading response..." response = client.read_response("GET") version, status, reason, headers, body = response puts "Got response: #{response.inspect}" puts body&.read end puts "Closing client..." client.close end puts "Exiting." protocol-http1-0.35.2/examples/http1/server.rb000077500000000000000000000015141506761724700212340ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2024, by Samuel Williams. $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) require "socket" require "protocol/http1/connection" require "protocol/http/body/buffered" # Test with: curl http://localhost:8080/ Addrinfo.tcp("0.0.0.0", 8080).listen do |server| loop do client, address = server.accept connection = Protocol::HTTP1::Connection.new(client) # Read request: while request = connection.read_request authority, method, path, version, headers, body = request # Write response: connection.write_response(version, 200, [["content-type", "text/plain"]]) connection.write_body(version, Protocol::HTTP::Body::Buffered.wrap(["Hello World"])) break unless connection.persistent end end end protocol-http1-0.35.2/fixtures/000077500000000000000000000000001506761724700163705ustar00rootroot00000000000000protocol-http1-0.35.2/fixtures/connection_context.rb000066400000000000000000000006361506761724700226250ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2024, by Samuel Williams. require "protocol/http1/connection" require "socket" ConnectionContext = Sus::Shared("a connection") do let(:sockets) {Socket.pair(Socket::PF_UNIX, Socket::SOCK_STREAM)} let(:client) {Protocol::HTTP1::Connection.new(sockets.first)} let(:server) {Protocol::HTTP1::Connection.new(sockets.last)} end protocol-http1-0.35.2/fuzz/000077500000000000000000000000001506761724700155155ustar00rootroot00000000000000protocol-http1-0.35.2/fuzz/request/000077500000000000000000000000001506761724700172055ustar00rootroot00000000000000protocol-http1-0.35.2/fuzz/request/bake.rb000066400000000000000000000003601506761724700204330ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2020-2022, by Samuel Williams. # Run the fuzz test. def run system("AFL_SKIP_BIN_CHECK=1 afl-fuzz -i input/ -o output/ -t 1000 -m 1000 -- ruby script.rb") end protocol-http1-0.35.2/fuzz/request/gems.rb000066400000000000000000000002251506761724700204640ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. eval_gemfile "../../gems.rb" gem "kisaten" protocol-http1-0.35.2/fuzz/request/input/000077500000000000000000000000001506761724700203445ustar00rootroot00000000000000protocol-http1-0.35.2/fuzz/request/input/body.txt000066400000000000000000000001311506761724700220350ustar00rootroot00000000000000POST /upload HTTP/1.1 Host: example.com Accept: */* Content-Length: 10 0123456789 protocol-http1-0.35.2/fuzz/request/input/simple.txt000066400000000000000000000000221506761724700223700ustar00rootroot00000000000000GET / HTTP/1.1 protocol-http1-0.35.2/fuzz/request/script.rb000077500000000000000000000012741506761724700210450ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true # Released under the MIT License. # Copyright, 2020-2025, by Samuel Williams. require "socket" require_relative "../../lib/protocol/http1" def test # input, output = Socket.pair(Socket::PF_UNIX, Socket::SOCK_STREAM) server = Protocol::HTTP1::Connection.new($stdin) # input.write($stdin.read) # input.close begin host, method, path, version, headers, body = server.read_request body = server.read_request_body(method, headers) rescue Protocol::HTTP1::InvalidRequest # Ignore. end end if ENV["_"] =~ /afl/ require "kisaten" Kisaten.crash_at [], [], Signal.list["USR1"] while Kisaten.loop 10000 test end else test end protocol-http1-0.35.2/gems.rb000066400000000000000000000007561506761724700160070ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. source "https://rubygems.org" gemspec group :maintenance, optional: true do gem "bake-modernize" gem "bake-gem" gem "bake-releases" gem "agent-context" gem "utopia-project" end group :test do gem "sus" gem "covered" gem "decode" gem "rubocop" gem "rubocop-md" gem "rubocop-socketry" gem "bake-test" gem "bake-test-external" gem "stringio" gem "traces" end protocol-http1-0.35.2/guides/000077500000000000000000000000001506761724700157775ustar00rootroot00000000000000protocol-http1-0.35.2/guides/getting-started/000077500000000000000000000000001506761724700211045ustar00rootroot00000000000000protocol-http1-0.35.2/guides/getting-started/readme.md000066400000000000000000000064001506761724700226630ustar00rootroot00000000000000# Getting Started This guide explains how to get started with `protocol-http1`, a low-level implementation of the HTTP/1 protocol for building HTTP clients and servers. ## Installation Add the gem to your project: ```bash $ bundle add protocol-http1 ``` ## Core Concepts `protocol-http1` provides a low-level implementation of the HTTP/1 protocol with several core concepts: - A {ruby Protocol::HTTP1::Connection} which represents the main entry point for creating HTTP/1.1 clients and servers. - Integration with the `Protocol::HTTP::Body` classes for handling request and response bodies. ## Usage `protocol-http1` can be used to build both HTTP clients and servers. ### HTTP Server Here's a simple HTTP/1.1 server that responds to all requests with "Hello World": ```ruby #!/usr/bin/env ruby require "socket" require "protocol/http1/connection" require "protocol/http/body/buffered" # Test with: curl http://localhost:8080/ Addrinfo.tcp("0.0.0.0", 8080).listen do |server| loop do client, address = server.accept connection = Protocol::HTTP1::Connection.new(client) # Read request: while request = connection.read_request authority, method, path, version, headers, body = request # Write response: connection.write_response(version, 200, [["content-type", "text/plain"]]) connection.write_body(version, Protocol::HTTP::Body::Buffered.wrap(["Hello World"])) break unless connection.persistent end end end ``` The server: 1. Creates a new {ruby Protocol::HTTP1::Connection} for each client connection. 2. Reads incoming requests using `read_request`. 3. Sends responses using `write_response` and `write_body`. 4. Supports persistent connections by checking `connection.persistent`. ### HTTP Client Here's a simple HTTP/1.1 client that makes multiple requests: ```ruby #!/usr/bin/env ruby require "async" require "async/http/endpoint" require "protocol/http1/connection" Async do endpoint = Async::HTTP::Endpoint.parse("http://localhost:8080") peer = endpoint.connect puts "Connected to #{peer} #{peer.remote_address.inspect}" # IO Buffering... client = Protocol::HTTP1::Connection.new(peer) puts "Writing request..." 3.times do client.write_request("localhost", "GET", "/", "HTTP/1.1", [["Accept", "*/*"]]) client.write_body("HTTP/1.1", nil) puts "Reading response..." response = client.read_response("GET") version, status, reason, headers, body = response puts "Got response: #{response.inspect}" puts body&.read end puts "Closing client..." client.close end ``` The client: 1. Creates a connection to a server using `Async::HTTP::Endpoint`. 2. Creates a {ruby Protocol::HTTP1::Connection} wrapper around the socket. 3. Sends requests using `write_request` and `write_body`. 4. Reads responses using `read_response`. 5. Properly closes the connection when done. ### Connection Management The {ruby Protocol::HTTP1::Connection} handles: - **Request/Response Parsing**: Automatically parses HTTP/1.1 request and response formats. - **Persistent Connections**: Supports HTTP/1.1 keep-alive for multiple requests over one connection. - **Body Handling**: Integrates with `Protocol::HTTP::Body` classes for streaming and buffered content. - **Header Management**: Properly handles HTTP headers as arrays of key-value pairs. protocol-http1-0.35.2/guides/links.yaml000066400000000000000000000000341506761724700200000ustar00rootroot00000000000000getting-started: order: 1 protocol-http1-0.35.2/lib/000077500000000000000000000000001506761724700152655ustar00rootroot00000000000000protocol-http1-0.35.2/lib/protocol/000077500000000000000000000000001506761724700171265ustar00rootroot00000000000000protocol-http1-0.35.2/lib/protocol/http1.rb000066400000000000000000000003661506761724700205200ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require_relative "http1/version" require_relative "http1/connection" # @namespace module Protocol # @namespace module HTTP1 end end protocol-http1-0.35.2/lib/protocol/http1/000077500000000000000000000000001506761724700201665ustar00rootroot00000000000000protocol-http1-0.35.2/lib/protocol/http1/body.rb000066400000000000000000000005241506761724700214510ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require_relative "body/chunked" require_relative "body/fixed" require_relative "body/remainder" module Protocol module HTTP1 # A collection of classes for handling HTTP/1.1 request and response bodies. module Body end end end protocol-http1-0.35.2/lib/protocol/http1/body/000077500000000000000000000000001506761724700211235ustar00rootroot00000000000000protocol-http1-0.35.2/lib/protocol/http1/body/chunked.rb000066400000000000000000000101641506761724700230730ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. # Copyright, 2023, by Thomas Morgan. require "protocol/http/body/readable" module Protocol module HTTP1 module Body # Represents a chunked body, which is a series of chunks, each with a length prefix. # # See https://tools.ietf.org/html/rfc7230#section-4.1 for more details on the chunked transfer encoding. class Chunked < HTTP::Body::Readable CRLF = "\r\n" # Initialize the chunked body. # # @parameter connection [Protocol::HTTP1::Connection] the connection to read the body from. # @parameter headers [Protocol::HTTP::Headers] the headers to read the trailer into, if any. def initialize(connection, headers) @connection = connection @finished = false @headers = headers @length = 0 @count = 0 end # @attribute [Integer] the number of chunks read so far. attr :count # @attribute [Integer] the length of the body if known. def length # We only know the length once we've read the final chunk: if @finished @length end end # @returns [Boolean] true if the body is empty, in other words {read} will return `nil`. def empty? @connection.nil? end # Close the connection and mark the body as finished. # # @parameter error [Exception | Nil] the error that caused the body to be closed, if any. def close(error = nil) if connection = @connection @connection = nil unless @finished connection.close_read end end super end VALID_CHUNK_LENGTH = /\A[0-9a-fA-F]+\z/ # Read a chunk of data. # # Follows the procedure outlined in https://tools.ietf.org/html/rfc7230#section-4.1.3 # # @returns [String | Nil] the next chunk of data, or `nil` if the body is finished. # @raises [EOFError] if the connection is closed before the expected length is read. def read if !@finished if @connection length, _extensions = @connection.read_line.split(";", 2) unless length =~ VALID_CHUNK_LENGTH raise BadRequest, "Invalid chunk length: #{length.inspect}" end # It is possible this line contains chunk extension, so we use `to_i` to only consider the initial integral part: length = Integer(length, 16) if length == 0 read_trailer # The final chunk has been read and the connection is now closed: @connection.receive_end_stream! @connection = nil @finished = true return nil end # Read trailing CRLF: chunk = @connection.read(length + 2) if chunk.bytesize == length + 2 # ...and chomp it off: chunk.chomp!(CRLF) @length += length @count += 1 return chunk else # The connection has been closed before we have read the requested length: @connection.close_read @connection = nil end end # If the connection has been closed before we have read the final chunk, raise an error: raise EOFError, "connection closed before expected length was read!" end end # @returns [String] a human-readable representation of the body. def inspect "\#<#{self.class} #{@length} bytes read in #{@count} chunks, #{@finished ? 'finished' : 'reading'}>" end # @returns [Hash] JSON representation for tracing and debugging. def as_json(...) super.merge( count: @count, finished: @finished, state: @connection ? "open" : "closed" ) end private # Read the trailer from the connection, and add any headers to the trailer. def read_trailer while line = @connection.read_line? # Empty line indicates end of trailer: break if line.empty? if match = line.match(HEADER) @headers.add(match[1], match[2]) else raise BadHeader, "Could not parse header: #{line.inspect}" end end end end end end end protocol-http1-0.35.2/lib/protocol/http1/body/fixed.rb000066400000000000000000000046241506761724700225550ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require "protocol/http/body/readable" module Protocol module HTTP1 module Body # Represents a fixed length body. class Fixed < HTTP::Body::Readable # Initialize the body with the given connection and length. # # @parameter connection [Protocol::HTTP1::Connection] the connection to read the body from. # @parameter length [Integer] the length of the body. def initialize(connection, length) @connection = connection @length = length @remaining = length end # @attribute [Integer] the length of the body. attr :length # @attribute [Integer] the remaining bytes to read. attr :remaining # @returns [Boolean] true if the body is empty. def empty? @connection.nil? or @remaining == 0 end # Close the connection. # # @parameter error [Exception | Nil] the error that caused the connection to be closed, if any. def close(error = nil) if connection = @connection @connection = nil unless @remaining == 0 connection.close_read end end super end # Read a chunk of data. # # @returns [String | Nil] the next chunk of data. # @raises [EOFError] if the connection is closed before the expected length is read. def read if @remaining > 0 if @connection # `readpartial` will raise `EOFError` if the connection is finished, or `IOError` if the connection is closed. chunk = @connection.readpartial(@remaining) @remaining -= chunk.bytesize if @remaining == 0 @connection.receive_end_stream! @connection = nil end return chunk end # If the connection has been closed before we have read the expected length, raise an error: raise EOFError, "connection closed before expected length was read!" end end # @returns [String] a human-readable representation of the body. def inspect "#<#{self.class} #{@length} bytes, #{@remaining} remaining, #{empty? ? 'finished' : 'reading'}>" end # @returns [Hash] JSON representation for tracing and debugging. def as_json(...) super.merge( remaining: @remaining, state: @connection ? "open" : "closed" ) end end end end end protocol-http1-0.35.2/lib/protocol/http1/body/remainder.rb000066400000000000000000000037431506761724700234250ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require "protocol/http/body/readable" module Protocol module HTTP1 module Body # Represents the remainder of the body, which reads all the data from the connection until it is finished. class Remainder < HTTP::Body::Readable BLOCK_SIZE = 1024 * 64 # Initialize the body with the given connection. # # @parameter connection [Protocol::HTTP1::Connection] the connection to read the body from. def initialize(connection, block_size: BLOCK_SIZE) @connection = connection @block_size = block_size end # @returns [Boolean] true if the body is empty. def empty? @connection.nil? end # Discard the body, which will close the connection and prevent further reads. def discard if connection = @connection @connection = nil # Ensure no further requests can be read from the connection, as we are discarding the body which may not be fully read: connection.close_read end end # Close the connection. # # @parameter error [Exception | Nil] the error that caused the connection to be closed, if any. def close(error = nil) self.discard super end # Read a chunk of data. # # @returns [String | Nil] the next chunk of data. def read @connection&.readpartial(@block_size) rescue EOFError if connection = @connection @connection = nil connection.receive_end_stream! end return nil end # @returns [String] a human-readable representation of the body. def inspect "#<#{self.class} #{@block_size} byte blocks, #{empty? ? 'finished' : 'reading'}>" end # @returns [Hash] JSON representation for tracing and debugging. def as_json(...) super.merge( block_size: @block_size, state: @connection ? "open" : "closed" ) end end end end end protocol-http1-0.35.2/lib/protocol/http1/connection.rb000066400000000000000000001047331506761724700226620ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. # Copyright, 2019, by Brian Morearty. # Copyright, 2020, by Bruno Sutic. # Copyright, 2023-2024, by Thomas Morgan. # Copyright, 2024, by Anton Zhuravsky. require "protocol/http/headers" require_relative "reason" require_relative "error" require_relative "body" require "protocol/http/body/head" require "protocol/http/methods" module Protocol module HTTP1 CONTENT_LENGTH = "content-length" TRANSFER_ENCODING = "transfer-encoding" CHUNKED = "chunked" CONNECTION = "connection" CLOSE = "close" KEEP_ALIVE = "keep-alive" HOST = "host" UPGRADE = "upgrade" # HTTP/1.x request line parser: TOKEN = /[!#$%&'*+\-\.\^_`|~0-9a-zA-Z]+/.freeze REQUEST_LINE = /\A(#{TOKEN}) ([^\s]+) (HTTP\/\d.\d)\z/.freeze # HTTP/1.x header parser: FIELD_NAME = TOKEN OWS = /[ \t]*/ # A field value is any string of characters that does not contain a null character, CR, or LF. After reflecting on the RFCs and surveying real implementations, I came to the conclusion that the RFCs are too restrictive. Most servers only check for the presence of null bytes, and obviously CR/LF characters have semantic meaning in the parser. So, I decided to follow this defacto standard, even if I'm not entirely happy with it. FIELD_VALUE = /[^\0\r\n]+/.freeze HEADER = /\A(#{FIELD_NAME}):#{OWS}(?:(#{FIELD_VALUE})#{OWS})?\z/.freeze VALID_FIELD_NAME = /\A#{FIELD_NAME}\z/.freeze VALID_FIELD_VALUE = /\A#{FIELD_VALUE}?\z/.freeze DEFAULT_MAXIMUM_LINE_LENGTH = 8192 # Represents a single HTTP/1.x connection, which may be used to send and receive multiple requests and responses. class Connection CRLF = "\r\n" # The HTTP/1.0 version string. HTTP10 = "HTTP/1.0" # The HTTP/1.1 version string. HTTP11 = "HTTP/1.1" # Initialize the connection with the given stream. # # @parameter stream [IO] the stream to read and write data from. # @parameter persistent [Boolean] whether the connection is persistent. # @parameter state [Symbol] the initial state of the connection, typically idle. def initialize(stream, persistent: true, state: :idle, maximum_line_length: DEFAULT_MAXIMUM_LINE_LENGTH) @stream = stream @persistent = persistent @state = state @count = 0 @maximum_line_length = maximum_line_length end # The underlying IO stream. attr :stream # @attribute [Boolean] true if the connection is persistent. # # This determines what connection headers are sent in the response and whether the connection can be reused after the response is sent. This setting is automatically managed according to the nature of the request and response. Changing to false is safe. Changing to true from outside this class should generally be avoided and, depending on the response semantics, may be reset to false anyway. attr_accessor :persistent # The current state of the connection. # # ``` # ┌────────┐ # │ │ # ┌───────────────────────►│ idle │ # │ │ │ # │ └───┬────┘ # │ │ # │ │ send request / # │ │ receive request # │ │ # │ ▼ # │ ┌────────┐ # │ recv ES │ │ send ES # │ ┌────────────┤ open ├────────────┐ # │ │ │ │ │ # │ ▼ └───┬────┘ ▼ # │ ┌──────────┐ │ ┌──────────┐ # │ │ half │ │ │ half │ # │ │ closed │ │ send R / │ closed │ # │ │ (remote) │ │ recv R │ (local) │ # │ └────┬─────┘ │ └─────┬────┘ # │ │ │ │ # │ │ send ES / │ recv ES / │ # │ │ close ▼ close │ # │ │ ┌────────┐ │ # │ └───────────►│ │◄───────────┘ # │ │ closed │ # └────────────────────────┤ │ # persistent └────────┘ # ``` # # - `ES`: the body was fully received or sent (end of stream). # - `R`: the connection was closed unexpectedly (reset). # # State transition methods use a trailing "!". attr_accessor :state # @return [Boolean] whether the connection is in the idle state. def idle? @state == :idle end # @return [Boolean] whether the connection is in the open state. def open? @state == :open end # @return [Boolean] whether the connection is in the half-closed local state. def half_closed_local? @state == :half_closed_local end # @return [Boolean] whether the connection is in the half-closed remote state. def half_closed_remote? @state == :half_closed_remote end # @return [Boolean] whether the connection is in the closed state. def closed? @state == :closed end # @attribute [Integer] the number of requests and responses processed by this connection. attr :count # Indicates whether the connection is persistent given the version, method, and headers. # # @parameter version [String] the HTTP version. # @parameter method [String] the HTTP method. # @parameter headers [Hash] the HTTP headers. # @return [Boolean] whether the connection can be persistent. def persistent?(version, method, headers) if method == HTTP::Methods::CONNECT return false end if version == HTTP10 if connection = headers[CONNECTION] return connection.keep_alive? else return false end else # HTTP/1.1+ if connection = headers[CONNECTION] return !connection.close? else return true end end end # Write the appropriate header for connection persistence. def write_connection_header(version) if version == HTTP10 @stream.write("connection: keep-alive\r\n") if @persistent else @stream.write("connection: close\r\n") unless @persistent end end # Write the appropriate header for connection upgrade. def write_upgrade_header(upgrade) @stream.write("connection: upgrade\r\nupgrade: #{upgrade}\r\n") end # Indicates whether the connection has been hijacked meaning its IO has been handed over and is not usable anymore. # # @returns [Boolean] hijack status def hijacked? @stream.nil? end # Hijack the connection - that is, take over the underlying IO and close the connection. # # @returns [IO | Nil] the underlying non-blocking IO. def hijack! @persistent = false if stream = @stream @stream = nil stream.flush @state = :hijacked self.closed return stream end end # Close the read end of the connection and transition to the half-closed remote state (or closed if already in the half-closed local state). def close_read unless @state == :closed @persistent = false @stream&.close_read self.receive_end_stream! end end # Close the connection and underlying stream and transition to the closed state. def close(error = nil) @persistent = false if stream = @stream @stream = nil stream.close end unless closed? @state = :closed self.closed(error) end end # Force a transition to the open state. # # @raises [ProtocolError] if the connection is not in the idle state. def open! unless @state == :idle raise ProtocolError, "Cannot open connection in state: #{@state}!" end @state = :open return self end # Write a request to the connection. It is expected you will write the body after this method. # # Transitions to the open state. # # @parameter authority [String] the authority of the request. # @parameter method [String] the HTTP method. # @parameter target [String] the request target. # @parameter version [String] the HTTP version. # @parameter headers [Hash] the HTTP headers. # @raises [ProtocolError] if the connection is not in the idle state. def write_request(authority, method, target, version, headers) open! @stream.write("#{method} #{target} #{version}\r\n") @stream.write("host: #{authority}\r\n") if authority write_headers(headers) end # Write a response to the connection. It is expected you will write the body after this method. # # @parameter version [String] the HTTP version. # @parameter status [Integer] the HTTP status code. # @parameter headers [Hash] the HTTP headers. # @parameter reason [String] the reason phrase, defaults to the standard reason phrase for the status code. def write_response(version, status, headers, reason = nil) reason ||= Reason::DESCRIPTIONS[status] unless @state == :open or @state == :half_closed_remote raise ProtocolError, "Cannot write response in state: #{@state}!" end # Safari WebSockets break if no reason is given: @stream.write("#{version} #{status} #{reason}\r\n") write_headers(headers) end # Write an interim response to the connection. It is expected you will eventually write the final response after this method. # # @parameter version [String] the HTTP version. # @parameter status [Integer] the HTTP status code. # @parameter headers [Hash] the HTTP headers. # @parameter reason [String] the reason phrase, defaults to the standard reason phrase for the status code. # @raises [ProtocolError] if the connection is not in the open or half-closed remote state. def write_interim_response(version, status, headers, reason = nil) reason ||= Reason::DESCRIPTIONS[status] unless @state == :open or @state == :half_closed_remote raise ProtocolError, "Cannot write interim response in state: #{@state}!" end @stream.write("#{version} #{status} #{reason}\r\n") write_headers(headers) @stream.write("\r\n") @stream.flush end # Write headers to the connection. # # @parameter headers [Hash] the headers to write. # @raises [BadHeader] if the header name or value is invalid. def write_headers(headers) headers.each do |name, value| # Convert it to a string: name = name.to_s value = value.to_s # Validate it: unless name.match?(VALID_FIELD_NAME) raise BadHeader, "Invalid header name: #{name.inspect}" end unless value.match?(VALID_FIELD_VALUE) raise BadHeader, "Invalid header value for #{name}: #{value.inspect}" end # Write it: @stream.write("#{name}: #{value}\r\n") end end # Read some data from the connection. # # @parameter length [Integer] the maximum number of bytes to read. def readpartial(length) @stream.readpartial(length) end # Read some data from the connection. # # @parameter length [Integer] the number of bytes to read. def read(length) @stream.read(length) end # Read a line from the connection. # # @returns [String | Nil] the line read, or nil if the connection is closed. # @raises [LineLengthError] if the line is too long. # @raises [ProtocolError] if the line is not terminated properly. def read_line? if line = @stream.gets(CRLF, @maximum_line_length) unless line.chomp!(CRLF) if line.bytesize == @maximum_line_length # This basically means that the request line, response line, header, or chunked length line is too long: raise LineLengthError, "Line too long!" else # This means the line was not terminated properly, which is a protocol violation: raise ProtocolError, "Line not terminated properly!" end end end return line # If a connection is shut down abruptly, we treat it as EOF, but only specifically in `read_line?`. rescue Errno::ECONNRESET return nil end # Read a line from the connection. # # @raises [EOFError] if a line could not be read. # @raises [LineLengthError] if the line is too long. def read_line read_line? or raise EOFError end # Read a request line from the connection. # # @returns [Tuple(String, String, String) | Nil] the method, path, and version of the request, or nil if the connection is closed. def read_request_line return unless line = read_line? if match = line.match(REQUEST_LINE) _, method, path, version = *match else raise InvalidRequest, line.inspect end return method, path, version end # Read a request from the connection, including the request line and request headers, and prepares to read the request body. # # Transitions to the open state. # # @yields {|host, method, path, version, headers, body| ...} if a block is given. # @returns [Tuple(String, String, String, String, HTTP::Headers, Protocol::HTTP1::Body) | Nil] the host, method, path, version, headers, and body of the request, or `nil` if the connection is closed. # @raises [ProtocolError] if the connection is not in the idle state. def read_request open! method, path, version = read_request_line return unless method headers = read_headers # If we are not persistent, we can't become persistent even if the request might allow it: if @persistent # In other words, `@persistent` can only transition from true to false. @persistent = persistent?(version, method, headers) end body = read_request_body(method, headers) unless body self.receive_end_stream! end @count += 1 if block_given? yield headers.delete(HOST), method, path, version, headers, body else return headers.delete(HOST), method, path, version, headers, body end end # Read a response line from the connection. # # @returns [Tuple(String, Integer, String)] the version, status, and reason of the response. # @raises [EOFError] if the connection is closed. def read_response_line version, status, reason = read_line.split(/\s+/, 3) status = Integer(status) return version, status, reason end # Indicates whether the status code is an interim status code. # # @parameter status [Integer] the status code. # @returns [Boolean] whether the status code is an interim status code. def interim_status?(status) status != 101 and status >= 100 and status < 200 end # Read a response from the connection. # # @parameter method [String] the HTTP method. # @yields {|version, status, reason, headers, body| ...} if a block is given. # @returns [Tuple(String, Integer, String, HTTP::Headers, Protocol::HTTP1::Body)] the version, status, reason, headers, and body of the response. # @raises [ProtocolError] if the connection is not in the open or half-closed local state. # @raises [EOFError] if the connection is closed. def read_response(method) unless @state == :open or @state == :half_closed_local raise ProtocolError, "Cannot read response in state: #{@state}!" end version, status, reason = read_response_line headers = read_headers if @persistent @persistent = persistent?(version, method, headers) end unless interim_status?(status) body = read_response_body(method, status, headers) unless body self.receive_end_stream! end @count += 1 end if block_given? yield version, status, reason, headers, body else return version, status, reason, headers, body end end # Read headers from the connection until an empty line is encountered. # # @returns [HTTP::Headers] the headers read. # @raises [EOFError] if the connection is closed. # @raises [BadHeader] if a header could not be parsed. def read_headers fields = [] while line = read_line # Empty line indicates end of headers: break if line.empty? if match = line.match(HEADER) fields << [match[1], match[2] || ""] else raise BadHeader, "Could not parse header: #{line.inspect}" end end return HTTP::Headers.new(fields) end # Transition to the half-closed local state, in other words, the connection is closed for writing. # # If the connection is already in the half-closed remote state, it will transition to the closed state. # # @raises [ProtocolError] if the connection is not in the open state. def send_end_stream! if @state == :open @state = :half_closed_local elsif @state == :half_closed_remote self.close! else raise ProtocolError, "Cannot send end stream in state: #{@state}!" end end # Write an upgrade body to the connection. # # This writes the upgrade header and the body to the connection. If the body is `nil`, you should coordinate writing to the stream. # # The connection will not be persistent after this method is called. # # @parameter protocol [String] the protocol to upgrade to. # @parameter body [Object | Nil] the body to write. # @returns [IO] the underlying IO stream. def write_upgrade_body(protocol, body = nil) # Once we upgrade the connection, it can no longer handle other requests: @persistent = false write_upgrade_header(protocol) @stream.write("\r\n") @stream.flush # Don't remove me! if body body.each do |chunk| @stream.write(chunk) @stream.flush end @stream.close_write end return @stream ensure self.send_end_stream! end # Write a tunnel body to the connection. # # This writes the connection header and the body to the connection. If the body is `nil`, you should coordinate writing to the stream. # # The connection will not be persistent after this method is called. # # @parameter version [String] the HTTP version. # @parameter body [Object | Nil] the body to write. # @returns [IO] the underlying IO stream. def write_tunnel_body(version, body = nil) @persistent = false write_connection_header(version) @stream.write("\r\n") @stream.flush # Don't remove me! if body body.each do |chunk| @stream.write(chunk) @stream.flush end @stream.close_write end return @stream ensure self.send_end_stream! end # Write an empty body to the connection. # # If given, the body will be closed. # # @parameter body [Object | Nil] the body to write. def write_empty_body(body = nil) @stream.write("content-length: 0\r\n\r\n") @stream.flush body&.close ensure self.send_end_stream! end # Write a fixed length body to the connection. # # If the request was a `HEAD` request, the body will be closed, and no data will be written. # # @parameter body [Object] the body to write. # @parameter length [Integer] the length of the body. # @parameter head [Boolean] whether the request was a `HEAD` request. # @raises [ContentLengthError] if the body length does not match the content length specified. def write_fixed_length_body(body, length, head) @stream.write("content-length: #{length}\r\n\r\n") if head @stream.flush body.close return end @stream.flush unless body.ready? chunk_length = 0 body.each do |chunk| chunk_length += chunk.bytesize if chunk_length > length raise ContentLengthError, "Trying to write #{chunk_length} bytes, but content length was #{length} bytes!" end @stream.write(chunk) @stream.flush unless body.ready? end @stream.flush if chunk_length != length raise ContentLengthError, "Wrote #{chunk_length} bytes, but content length was #{length} bytes!" end ensure self.send_end_stream! end # Write a chunked body to the connection. # # If the request was a `HEAD` request, the body will be closed, and no data will be written. # # If trailers are given, they will be written after the body. # # @parameter body [Object] the body to write. # @parameter head [Boolean] whether the request was a `HEAD` request. # @parameter trailer [Hash | Nil] the trailers to write. def write_chunked_body(body, head, trailer = nil) @stream.write("transfer-encoding: chunked\r\n\r\n") if head @stream.flush body.close return end @stream.flush unless body.ready? body.each do |chunk| next if chunk.size == 0 @stream.write("#{chunk.bytesize.to_s(16).upcase}\r\n") @stream.write(chunk) @stream.write(CRLF) @stream.flush unless body.ready? end if trailer&.any? @stream.write("0\r\n") write_headers(trailer) @stream.write("\r\n") else @stream.write("0\r\n\r\n") end @stream.flush ensure self.send_end_stream! end # Write the body to the connection and close the connection. # # @parameter body [Object] the body to write. # @parameter head [Boolean] whether the request was a `HEAD` request. def write_body_and_close(body, head) # We can't be persistent because we don't know the data length: @persistent = false @stream.write("\r\n") @stream.flush unless body.ready? if head body.close else body.each do |chunk| @stream.write(chunk) @stream.flush unless body.ready? end end @stream.flush @stream.close_write ensure self.send_end_stream! end # The connection (stream) was closed. It may now be in the idle state. # # Sub-classes may override this method to perform additional cleanup. # # @parameter error [Exception | Nil] the error that caused the connection to be closed, if any. def closed(error = nil) end # Transition to the closed state. # # If no error occurred, and the connection is persistent, this will immediately transition to the idle state. # # @parameter error [Exxception] the error that caused the connection to close. def close!(error = nil) if @persistent and !error # If there was no error, and the connection is persistent, we can reuse it: @state = :idle else @state = :closed end self.closed(error) end # Write a body to the connection. # # The behavior of this method is determined by the HTTP version, the body, and the request method. We try to choose the best approach possible, given the constraints, connection persistence, whether the length is known, etc. # # @parameter version [String] the HTTP version. # @parameter body [Object] the body to write. # @parameter head [Boolean] whether the request was a `HEAD` request. # @parameter trailer [Hash | Nil] the trailers to write. def write_body(version, body, head = false, trailer = nil) # HTTP/1.0 cannot in any case handle trailers. if version == HTTP10 # or te: trailers was not present (strictly speaking not required.) trailer = nil end # While writing the body, we don't know if trailers will be added. We must choose a different body format depending on whether there is the chance of trailers, even if trailer.any? is currently false. # # Below you notice `and trailer.nil?`. I tried this but content-length is more important than trailers. if body.nil? write_connection_header(version) write_empty_body(body) elsif length = body.length # and trailer.nil? write_connection_header(version) write_fixed_length_body(body, length, head) elsif body.empty? # Even thought this code is the same as the first clause `body.nil?`, HEAD responses have an empty body but still carry a content length. `write_fixed_length_body` takes care of this appropriately. write_connection_header(version) write_empty_body(body) elsif version == HTTP11 write_connection_header(version) # We specifically ensure that non-persistent connections do not use chunked response, so that hijacking works as expected. write_chunked_body(body, head, trailer) else @persistent = false write_connection_header(version) write_body_and_close(body, head) end end # Indicate that the end of the stream (body) has been received. # # This will transition to the half-closed remote state if the connection is open, or the closed state if the connection is half-closed local. # # @raises [ProtocolError] if the connection is not in the open or half-closed remote state. def receive_end_stream! if @state == :open @state = :half_closed_remote elsif @state == :half_closed_local self.close! else raise ProtocolError, "Cannot receive end stream in state: #{@state}!" end end # Read the body, assuming it is using the chunked transfer encoding. # # @parameters headers [Hash] the headers of the request. # @returns [Protocol::HTTP1::Body::Chunked] the body. def read_chunked_body(headers) Body::Chunked.new(self, headers) end # Read the body, assuming it has a fixed length. # # @parameters length [Integer] the length of the body. # @returns [Protocol::HTTP1::Body::Fixed] the body. def read_fixed_body(length) Body::Fixed.new(self, length) end # Read the body, assuming that we read until the connection is closed. # # @returns [Protocol::HTTP1::Body::Remainder] the body. def read_remainder_body @persistent = false Body::Remainder.new(self) end # Read the body, assuming that we are not receiving any actual data, but just the length. # # @parameters length [Integer] the length of the body. # @returns [Protocol::HTTP::Body::Head] the body. def read_head_body(length) # We are not receiving any body: self.receive_end_stream! Protocol::HTTP::Body::Head.new(length) end # Read the body, assuming it is a tunnel. # # Invokes {read_remainder_body}. # # @returns [Protocol::HTTP::Body::Remainder] the body. def read_tunnel_body read_remainder_body end # Read the body, assuming it is an upgrade. # # Invokes {read_remainder_body}. # # @returns [Protocol::HTTP::Body::Remainder] the body. def read_upgrade_body # When you have an incoming upgrade request body, we must be extremely careful not to start reading it until the upgrade has been confirmed, otherwise if the upgrade was rejected and we started forwarding the incoming request body, it would desynchronize the connection (potential security issue). # We mitigate this issue by setting @persistent to false, which will prevent the connection from being reused, even if the upgrade fails (potential performance issue). read_remainder_body end # The HTTP `HEAD` method. HEAD = "HEAD" # The HTTP `CONNECT` method. CONNECT = "CONNECT" # The pattern for valid content length values. VALID_CONTENT_LENGTH = /\A\d+\z/ # Extract the content length from the headers, if possible. # # @parameter headers [Hash] the headers. # @yields {|length| ...} if a content length is found. # @parameter length [Integer] the content length. # @raises [BadRequest] if the content length is invalid. def extract_content_length(headers) if content_length = headers.delete(CONTENT_LENGTH) if content_length =~ VALID_CONTENT_LENGTH yield Integer(content_length, 10) else raise BadRequest, "Invalid content length: #{content_length.inspect}" end end end # Read the body of the response. # # - The `HEAD` method is used to retrieve the headers of the response without the body, so {read_head_body} is invoked if there is a content length, otherwise nil is returned. # - A 101 status code indicates that the connection will be upgraded, so {read_upgrade_body} is invoked. # - Interim status codes (1xx), no content (204) and not modified (304) status codes do not have a body, so nil is returned. # - The `CONNECT` method is used to establish a tunnel, so {read_tunnel_body} is invoked. # - Otherwise, the body is read according to {read_body}. # # @parameter method [String] the HTTP method. # @parameter status [Integer] the HTTP status code. # @parameter headers [Hash] the headers of the response. def read_response_body(method, status, headers) # RFC 7230 3.3.3 # 1. Any response to a HEAD request and any response with a 1xx # (Informational), 204 (No Content), or 304 (Not Modified) status # code is always terminated by the first empty line after the # header fields, regardless of the header fields present in the # message, and thus cannot contain a message body. if method == HTTP::Methods::HEAD extract_content_length(headers) do |length| if length > 0 return read_head_body(length) else return nil end end # There is no body for a HEAD request if there is no content length: return nil end if status == 101 return read_upgrade_body end if (status >= 100 and status < 200) or status == 204 or status == 304 return nil end # 2. Any 2xx (Successful) response to a CONNECT request implies that # the connection will become a tunnel immediately after the empty # line that concludes the header fields. A client MUST ignore any # Content-Length or Transfer-Encoding header fields received in # such a message. if method == HTTP::Methods::CONNECT and status == 200 return read_tunnel_body end return read_body(headers, true) end # Read the body of the request. # # - The `CONNECT` method is used to establish a tunnel, so the body is read until the connection is closed. # - The `UPGRADE` method is used to upgrade the connection to a different protocol (typically WebSockets), so the body is read until the connection is closed. # - Otherwise, the body is read according to {read_body}. # # @parameter method [String] the HTTP method. # @parameter headers [Hash] the headers of the request. def read_request_body(method, headers) # 2. Any 2xx (Successful) response to a CONNECT request implies that # the connection will become a tunnel immediately after the empty # line that concludes the header fields. A client MUST ignore any # Content-Length or Transfer-Encoding header fields received in # such a message. if method == HTTP::Methods::CONNECT return read_tunnel_body end # A successful upgrade response implies that the connection will become a tunnel immediately after the empty line that concludes the header fields. if headers[UPGRADE] return read_upgrade_body end # 6. If this is a request message and none of the above are true, then # the message body length is zero (no message body is present). return read_body(headers) end # Read the body of the message. # # - The `transfer-encoding` header is used to determine if the body is chunked. # - Otherwise, if the `content-length` is present, the body is read until the content length is reached. # - Otherwise, if `remainder` is true, the body is read until the connection is closed. # # @parameter headers [Hash] the headers of the message. # @parameter remainder [Boolean] whether to read the remainder of the body. # @returns [Object] the body. def read_body(headers, remainder = false) # 3. If a Transfer-Encoding header field is present and the chunked # transfer coding (Section 4.1) is the final encoding, the message # body length is determined by reading and decoding the chunked # data until the transfer coding indicates the data is complete. if transfer_encoding = headers.delete(TRANSFER_ENCODING) # If a message is received with both a Transfer-Encoding and a # Content-Length header field, the Transfer-Encoding overrides the # Content-Length. Such a message might indicate an attempt to # perform request smuggling (Section 9.5) or response splitting # (Section 9.4) and ought to be handled as an error. A sender MUST # remove the received Content-Length field prior to forwarding such # a message downstream. if headers[CONTENT_LENGTH] raise BadRequest, "Message contains both transfer encoding and content length!" end if transfer_encoding.last == CHUNKED return read_chunked_body(headers) else # If a Transfer-Encoding header field is present in a response and # the chunked transfer coding is not the final encoding, the # message body length is determined by reading the connection until # it is closed by the server. If a Transfer-Encoding header field # is present in a request and the chunked transfer coding is not # the final encoding, the message body length cannot be determined # reliably; the server MUST respond with the 400 (Bad Request) # status code and then close the connection. return read_remainder_body end end # 5. If a valid Content-Length header field is present without # Transfer-Encoding, its decimal value defines the expected message # body length in octets. If the sender closes the connection or # the recipient times out before the indicated number of octets are # received, the recipient MUST consider the message to be # incomplete and close the connection. extract_content_length(headers) do |length| if length > 0 return read_fixed_body(length) else return nil end end # http://tools.ietf.org/html/rfc2068#section-19.7.1.1 if remainder # 7. Otherwise, this is a response message without a declared message # body length, so the message body length is determined by the # number of octets received prior to the server closing the # connection. return read_remainder_body end end end end end protocol-http1-0.35.2/lib/protocol/http1/error.rb000066400000000000000000000017221506761724700216460ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require "protocol/http/error" module Protocol module HTTP1 # The base class for all HTTP/1.x errors. class Error < HTTP::Error end # The protocol was violated in some way, e.g. trying to write a request while reading a response. class ProtocolError < Error end # The request line was too long. class LineLengthError < Error end # The request was not able to be parsed correctly, or failed some kind of validation. class BadRequest < Error end # A header name or value was invalid, e.g. contains invalid characters. class BadHeader < BadRequest end # Indicates that the request is invalid for some reason, e.g. syntax error, invalid headers, etc. class InvalidRequest < BadRequest end # The specified content length and the given content's length do not match. class ContentLengthError < Error end end end protocol-http1-0.35.2/lib/protocol/http1/reason.rb000066400000000000000000000043171506761724700220070ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require "protocol/http/error" module Protocol module HTTP1 # Reason phrases for HTTP status codes. module Reason # Get the reason phrase for the given status code. DESCRIPTIONS = { 100 => "Continue", 101 => "Switching Protocols", 102 => "Processing", 103 => "Early Hints", 200 => "OK", 201 => "Created", 202 => "Accepted", 203 => "Non-Authoritative Information", 204 => "No Content", 205 => "Reset Content", 206 => "Partial Content", 207 => "Multi-Status", 208 => "Already Reported", 226 => "IM Used", 300 => "Multiple Choices", 301 => "Moved Permanently", 302 => "Found", 303 => "See Other", 304 => "Not Modified", 305 => "Use Proxy", # no longer used, but included for completeness 306 => "Switch Proxy", 307 => "Temporary Redirect", 308 => "Permanent Redirect", 400 => "Bad Request", 401 => "Unauthorized", 402 => "Payment Required", 403 => "Forbidden", 404 => "Not Found", 405 => "Method Not Allowed", 406 => "Not Acceptable", 407 => "Proxy Authentication Required", 408 => "Request Timeout", 409 => "Conflict", 410 => "Gone", 411 => "Length Required", 412 => "Precondition Failed", 413 => "Payload Too Large", 414 => "URI Too Long", 415 => "Unsupported Media Type", 416 => "Range Not Satisfiable", 417 => "Expectation Failed", 421 => "Misdirected Request", 422 => "Unprocessable Entity", 423 => "Locked", 424 => "Failed Dependency", 426 => "Upgrade Required", 428 => "Precondition Required", 429 => "Too Many Requests", 431 => "Request Header Fields Too Large", 451 => "Unavailable for Legal Reasons", 500 => "Internal Server Error", 501 => "Not Implemented", 502 => "Bad Gateway", 503 => "Service Unavailable", 504 => "Gateway Timeout", 505 => "HTTP Version Not Supported", 506 => "Variant Also Negotiates", 507 => "Insufficient Storage", 508 => "Loop Detected", 510 => "Not Extended", 511 => "Network Authentication Required" }.freeze end end end protocol-http1-0.35.2/lib/protocol/http1/version.rb000066400000000000000000000002521506761724700221770ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. module Protocol module HTTP1 VERSION = "0.35.2" end end protocol-http1-0.35.2/lib/traces/000077500000000000000000000000001506761724700165465ustar00rootroot00000000000000protocol-http1-0.35.2/lib/traces/provider/000077500000000000000000000000001506761724700204005ustar00rootroot00000000000000protocol-http1-0.35.2/lib/traces/provider/protocol/000077500000000000000000000000001506761724700222415ustar00rootroot00000000000000protocol-http1-0.35.2/lib/traces/provider/protocol/http1.rb000066400000000000000000000002151506761724700236240ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require_relative "http1/connection" protocol-http1-0.35.2/lib/traces/provider/protocol/http1/000077500000000000000000000000001506761724700233015ustar00rootroot00000000000000protocol-http1-0.35.2/lib/traces/provider/protocol/http1/connection.rb000066400000000000000000000031421506761724700257650ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require_relative "../../../../protocol/http1/connection" Traces::Provider(Protocol::HTTP1::Connection) do def write_request(authority, method, target, version, headers) attributes = { authority: authority, method: method, target: target, version: version, headers: headers&.to_h, } Traces.trace("protocol.http1.connection.write_request", attributes: attributes) do super end end def write_response(version, status, headers, reason = nil) attributes = { version: version, status: status, headers: headers&.to_h, } Traces.trace("protocol.http1.connection.write_response", attributes: attributes) do super end end def write_interim_response(version, status, headers, reason = nil) attributes = { version: version, status: status, headers: headers&.to_h, reason: reason, } Traces.trace("protocol.http1.connection.write_interim_response", attributes: attributes) do super end end def write_body(version, body, head = false, trailer = nil) attributes = { version: version, head: head, trailer: trailer, body: body&.as_json, } Traces.trace("protocol.http1.connection.write_body", attributes: attributes) do |span| super rescue => error # Capture the body state at the time of the error for EPIPE debugging: span["error.body"] = body&.as_json span["error.connection"] = { state: @state, persistent: @persistent, count: @count, stream_closed: @stream.nil? } raise error end end end protocol-http1-0.35.2/license.md000066400000000000000000000023711506761724700164660ustar00rootroot00000000000000# MIT License Copyright, 2019-2025, by Samuel Williams. Copyright, 2019, by Brian Morearty. Copyright, 2020, by Olle Jonsson. Copyright, 2020, by Bruno Sutic. Copyright, 2023-2024, by Thomas Morgan. Copyright, 2024, by Anton Zhuravsky. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. protocol-http1-0.35.2/protocol-http1.gemspec000066400000000000000000000016211506761724700207630ustar00rootroot00000000000000# frozen_string_literal: true require_relative "lib/protocol/http1/version" Gem::Specification.new do |spec| spec.name = "protocol-http1" spec.version = Protocol::HTTP1::VERSION spec.summary = "A low level implementation of the HTTP/1 protocol." spec.authors = ["Samuel Williams", "Thomas Morgan", "Anton Zhuravsky", "Brian Morearty", "Bruno Sutic", "Olle Jonsson"] spec.license = "MIT" spec.cert_chain = ["release.cert"] spec.signing_key = File.expand_path("~/.gem/release.pem") spec.homepage = "https://github.com/socketry/protocol-http1" spec.metadata = { "documentation_uri" => "https://socketry.github.io/protocol-http1/", "source_code_uri" => "https://github.com/socketry/protocol-http1.git", } spec.files = Dir.glob(["{context,lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) spec.required_ruby_version = ">= 3.2" spec.add_dependency "protocol-http", "~> 0.22" end protocol-http1-0.35.2/readme.md000066400000000000000000000057031506761724700163030ustar00rootroot00000000000000# Protocol::HTTP1 Provides a low-level implementation of the HTTP/1 protocol. [![Development Status](https://github.com/socketry/protocol-http1/workflows/Test/badge.svg)](https://github.com/socketry/protocol-http1/actions?workflow=Test) ## Installation Add this line to your application's Gemfile: ``` ruby gem 'protocol-http1' ``` And then execute: $ bundle Or install it yourself as: $ gem install protocol-http1 ## Usage Please see the [project documentation](https://socketry.github.io/protocol-http1/) for more details. - [Getting Started](https://socketry.github.io/protocol-http1/guides/getting-started/index) - This guide explains how to get started with `protocol-http1`, a low-level implementation of the HTTP/1 protocol for building HTTP clients and servers. ## Releases Please see the [project releases](https://socketry.github.io/protocol-http1/releases/index) for all releases. ### v0.35.2 - Tidy up implementation of `read_line?` to handle line length errors and protocol violations more clearly. - Improve error handling for unexpected connection closures (`Errno::ECONNRESET`) in `read_line?`. ### v0.35.0 - Add traces provider for `Protocol::HTTP1::Connection`. ### v0.34.1 - Fix connection state handling to allow idempotent response body closing. - Add `kisaten` fuzzing integration for improved security testing. ### v0.34.0 - Support empty header values in HTTP parsing for better compatibility. ### v0.33.0 - Support high-byte characters in HTTP headers for improved international compatibility. ### v0.32.0 - Fix header parsing to handle tab characters between values correctly. - Complete documentation coverage for all public APIs. ### v0.31.0 - Enforce one-way transition for persistent connections to prevent invalid state changes. ### v0.30.0 - Make `authority` header optional in HTTP requests for improved flexibility. ### v0.29.0 - Add block/yield interface to `read_request` and `read_response` methods. ### v0.28.1 - Fix handling of `nil` lines in HTTP parsing. ## Contributing We welcome contributions to this project. 1. Fork it. 2. Create your feature branch (`git checkout -b my-new-feature`). 3. Commit your changes (`git commit -am 'Add some feature'`). 4. Push to the branch (`git push origin my-new-feature`). 5. Create new Pull Request. ### Developer Certificate of Origin In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed. ### Community Guidelines This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers. protocol-http1-0.35.2/release.cert000066400000000000000000000033141506761724700170170ustar00rootroot00000000000000-----BEGIN CERTIFICATE----- MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11 ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2 9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/ c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp 8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8 voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg= -----END CERTIFICATE----- protocol-http1-0.35.2/releases.md000066400000000000000000000143011506761724700166430ustar00rootroot00000000000000# Releases ## v0.35.2 - Tidy up implementation of `read_line?` to handle line length errors and protocol violations more clearly. - Improve error handling for unexpected connection closures (`Errno::ECONNRESET`) in `read_line?`. ## v0.35.0 - Add traces provider for `Protocol::HTTP1::Connection`. ## v0.34.1 - Fix connection state handling to allow idempotent response body closing. - Add `kisaten` fuzzing integration for improved security testing. ## v0.34.0 - Support empty header values in HTTP parsing for better compatibility. ## v0.33.0 - Support high-byte characters in HTTP headers for improved international compatibility. ## v0.32.0 - Fix header parsing to handle tab characters between values correctly. - Complete documentation coverage for all public APIs. ## v0.31.0 - Enforce one-way transition for persistent connections to prevent invalid state changes. ## v0.30.0 - Make `authority` header optional in HTTP requests for improved flexibility. ## v0.29.0 - Add block/yield interface to `read_request` and `read_response` methods. ## v0.28.1 - Fix handling of `nil` lines in HTTP parsing. ## v0.28.0 - Add configurable maximum line length to prevent denial of service attacks. ## v0.27.0 - Improve error message clarity and debugging information. - Separate state machine logic from connection callbacks for better architecture. ## v0.26.0 - Improve error handling propagation through connection closure. ## v0.25.0 - Fix connection stream handling when closing response bodies. - Improve connection state management for better reliability. ## v0.24.0 - Add connection state tracking for safer connection reuse. ## v0.23.0 - Add `Body#discard` method support for improved resource management. ## v0.22.0 - Improve handling of underlying stream objects for better stability. ## v0.21.0 - Fix connection persistence handling for `1xx` responses and remainder bodies. - Improve debug output readability by using `.inspect` instead of `.dump`. - Enhanced request upgrade body handling. ## v0.20.0 - Restructure error hierarchy for better error handling consistency. ## v0.19.1 - Fix stream flushing in `write_body_and_close` for proper connection cleanup. ## v0.19.0 - Add `#hijacked?` method to check connection hijack status. ## v0.18.0 - Add persistent connection handling examples. - Improve performance by avoiding blocking operations on `eof?` checks. ## v0.17.0 - Add `HTTP/1` client and server example implementations. ## v0.16.1 - Allow external control of persistent connection settings. - Separate request line and response status line parsing for better maintainability. ## v0.16.0 - Add support for HTTP interim (informational) responses like `103 Early Hints`. - Improve error messages by including `content_length` in debugging output. ## v0.15.1 - Add strict validation for `content-length` and chunk length values. ## v0.15.0 - Migrate test suite to `Sus` testing framework with 100% coverage. ## v0.14.6 - Handle `IOError` for closed streams gracefully. - Improve memory management by removing string ownership model. - Add early hints server example. ## v0.14.4 - Improve trailer handling when content length is known in advance. ## v0.14.3 - Enhanced trailer support with comprehensive test coverage. ## v0.14.2 - Prefer chunked transfer encoding when possible for better streaming performance. ## v0.14.1 - Improve error handling when reading chunk length lines. ## v0.14.0 - Rename "trailers" to "trailer" for HTTP specification compliance. ## v0.13.2 - Enable `HTTP/1.1` connections to write fixed-length message bodies. ## v0.13.1 - Fix `HTTP/1` request parsing example in documentation. ## v0.13.0 - Implement pessimistic flushing strategy for better performance. - Add fuzzing infrastructure for security testing. ## v0.12.0 - Update dependencies to latest compatible versions. ## v0.11.1 - Improve header and trailer processing logic. - Update behavior to match new `write_body` semantics. ## v0.11.0 - Add comprehensive HTTP trailer support for chunked transfers. - Simplify chunked encoding implementation. ## v0.10.3 - Improve handling of `HEAD` requests and responses. - Better error handling for incomplete fixed-length message bodies. ## v0.10.2 - Add RFC-compliant header validation during read and write operations. - Improve performance with `frozen_string_literals: true`. ## v0.10.1 - Drop support for Ruby 2.3 (end of life). - Validate that response header values don't contain `CR` or `LF` characters. ## v0.10.0 - Parse HTTP `connection` header values as case-insensitive per RFC specification. ## v0.9.0 - Enhanced `Remainder` body implementation with comprehensive test coverage. - Improve HTTP `CONNECT` method handling for both client and server. - Improve performance by removing array allocation in method arguments. ## v0.8.3 - Restore Ruby 2.3 compatibility using monkey patches. - Enhanced test suite with improved memory and file handling utilities. ## v0.8.2 - Simplify HTTP request line validation logic. ## v0.8.1 - Improve error handling and recovery for malformed HTTP requests. ## v0.8.0 - Add automatic HTTP reason phrase generation based on status codes. ## v0.7.0 - Enhanced connection hijacking support for pooled connections. ## v0.6.0 - Adopt `Protocol::HTTP` Body abstractions for better consistency. - Require callers to handle hijacking for `HTTP/1` protocol upgrades. - Add flexible request/response body and upgrade handling. - Fix WebSocket compatibility issues with Safari browser. ## v0.5.0 - Return `nil` when unable to read HTTP request line (connection closed). ## v0.4.1 - Ensure output streams are properly closed within accept blocks. ## v0.4.0 - Improve handling of HTTP upgrade request and response message bodies. ## v0.3.0 - Enhanced support for partial connection hijacking and protocol upgrades. ## v0.2.0 - Improve error handling throughout the codebase. ## v0.1.0 - Initial public release of `Protocol::HTTP1`. - Low-level `HTTP/1.0` and `HTTP/1.1` protocol implementation. - Support for persistent connections, chunked transfer encoding, and connection upgrades. protocol-http1-0.35.2/test/000077500000000000000000000000001506761724700154765ustar00rootroot00000000000000protocol-http1-0.35.2/test/protocol/000077500000000000000000000000001506761724700173375ustar00rootroot00000000000000protocol-http1-0.35.2/test/protocol/http1.rb000066400000000000000000000004041506761724700207220ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2024, by Samuel Williams. require "protocol/http1/version" describe Protocol::HTTP1 do it "has a version number" do expect(Protocol::HTTP1::VERSION).not.to be_nil end end protocol-http1-0.35.2/test/protocol/http1/000077500000000000000000000000001506761724700203775ustar00rootroot00000000000000protocol-http1-0.35.2/test/protocol/http1/body/000077500000000000000000000000001506761724700213345ustar00rootroot00000000000000protocol-http1-0.35.2/test/protocol/http1/body/chunked.rb000066400000000000000000000067421506761724700233130ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require "protocol/http1/body/chunked" require "connection_context" describe Protocol::HTTP1::Body::Chunked do let(:content) {"Hello World"} let(:postfix) {nil} let(:headers) {Protocol::HTTP::Headers.new} let(:buffer) {StringIO.new("#{content.bytesize.to_s(16)}\r\n#{content}\r\n0\r\n#{postfix}\r\n")} let(:connection) {Protocol::HTTP1::Connection.new(buffer, state: :open)} let(:body) {subject.new(connection, headers)} with "#inspect" do it "can be inspected" do expect(body.inspect).to be =~ /0 bytes read in 0 chunks, reading/ end end with "#as_json" do it "returns JSON representation" do expect(body.as_json).to have_keys( class: be == "Protocol::HTTP1::Body::Chunked", length: be_nil, # Not finished yet stream: be == false, ready: be == false, empty: be == false, count: be == 0, finished: be == false, state: be == "open" ) end it "shows finished state after reading all chunks" do body.read # Read the chunk body.read # Read the end (returns nil) expect(body.as_json).to have_keys( length: be == 11, count: be == 1, finished: be == true, empty: be == true, state: be == "closed" ) end end with "#empty?" do it "returns whether EOF was reached" do expect(body.empty?).to be == false end end with "#close" do it "invokes close_read on the stream if closing without reading all chunks" do expect(buffer).to receive(:close_read) body.close expect(body).to be(:empty?) expect(connection).to be(:half_closed_remote?) end it "invokes close_read on the stream if closing with an error" do expect(buffer).to receive(:close_read) body.close(EOFError) expect(body).to be(:empty?) expect(connection).to be(:half_closed_remote?) end end with "#read" do it "retrieves chunks of content" do expect(body.read).to be == "Hello World" expect(body.read).to be == nil expect(body.read).to be == nil expect(connection).to be(:half_closed_remote?) end it "updates number of bytes retrieved" do expect(body).to have_attributes(length: be_nil, count: be == 0) expect(body.read).to be == "Hello World" expect(body.read).to be_nil # there are no more chunks expect(body).to have_attributes(length: be == 11, count: be == 1) expect(body).to be(:empty?) expect(connection).to be(:half_closed_remote?) end with "trailer" do let(:postfix) {"ETag: abcd\r\n"} it "can read trailing etag" do headers.add("trailer", "etag") expect(body.read).to be == "Hello World" expect(headers["etag"]).to be_nil expect(body.read).to be == nil expect(headers["etag"]).to be == "abcd" expect(connection).to be(:half_closed_remote?) end end with "bad trailers" do let(:postfix) {":ETag abcd\r\n"} it "raises error" do headers.add("trailer", "etag") expect(body.read).to be == "Hello World" expect(headers["etag"]).to be_nil expect{body.read}.to raise_exception(Protocol::HTTP1::BadHeader) body.close expect(connection).to be(:half_closed_remote?) end end with "invalid content length" do let(:buffer) {StringIO.new("#{(content.bytesize + 1).to_s(16)}\r\n#{content}")} it "raises error" do expect{body.read}.to raise_exception(EOFError) expect(connection).to be(:half_closed_remote?) end end end end protocol-http1-0.35.2/test/protocol/http1/body/fixed.rb000066400000000000000000000064011506761724700227610ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require "protocol/http1/body/fixed" require "protocol/http1/connection" describe Protocol::HTTP1::Body::Fixed do let(:content) {"Hello World"} let(:buffer) {StringIO.new(content)} let(:connection) {Protocol::HTTP1::Connection.new(buffer, state: :open)} let(:body) {subject.new(connection, content.bytesize)} with "#inspect" do it "can be inspected" do expect(body.inspect).to be =~ /11 bytes, 11 remaining, reading/ end end with "#as_json" do it "returns JSON representation" do expect(body.as_json).to have_keys( class: be == "Protocol::HTTP1::Body::Fixed", length: be == 11, stream: be == false, ready: be == false, empty: be == false, remaining: be == 11, state: be == "open" ) end it "shows finished state when empty" do body.read # Read all data expect(body.as_json).to have_keys( remaining: be == 0, empty: be == true, state: be == "closed" ) end end with "#empty?" do it "returns whether EOF was reached" do expect(body.empty?).to be == false end end with "#stop" do it "closes the stream" do body.close(EOFError) expect(buffer).to be(:closed?) expect(connection).to be(:half_closed_remote?) end it "doesn't close the stream when EOF was reached" do body.read body.close(EOFError) expect(buffer).not.to be(:closed?) expect(connection).to be(:half_closed_remote?) end it "causes #read to raise EOFError" do body.close expect do body.read end.to raise_exception(EOFError) end end with "#read" do it "retrieves chunks of content" do expect(body.read).to be == "Hello World" expect(body.read).to be == nil expect(connection).to be(:half_closed_remote?) end it "updates number of bytes retrieved" do body.read expect(body).to be(:empty?) end with "length smaller than stream size" do let(:body) {subject.new(connection, 5)} it "retrieves content up to provided length" do expect(body.read).to be == "Hello" expect(body.read).to be == nil expect(connection).to be(:half_closed_remote?) end it "updates number of bytes retrieved" do expect(body).to have_attributes(remaining: be == body.length) body.read expect(body).to have_attributes(remaining: be == 0) expect(body).to be(:empty?) expect(connection).to be(:half_closed_remote?) end end with "length larger than stream size" do let(:body) {subject.new(connection, 20)} it "retrieves content up to provided length" do body.read expect do body.read end.to raise_exception(EOFError) end end end with "#join" do it "returns all content" do expect(body.join).to be == "Hello World" end it "updates number of bytes retrieved" do chunk = body.read expect(body).to be(:empty?) expect(body).to have_attributes( length: be == chunk.bytesize, remaining: be == 0 ) expect(connection).to be(:half_closed_remote?) end end with "#discard" do it "causes #read to return nil" do body.discard expect(body.read).to be == nil expect(connection).to be(:half_closed_remote?) end end end protocol-http1-0.35.2/test/protocol/http1/body/remainder.rb000066400000000000000000000045301506761724700236310ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require "protocol/http1/body/remainder" require "protocol/http1/connection" describe Protocol::HTTP1::Body::Remainder do let(:content) {"Hello World"} let(:buffer) {StringIO.new(content)} let(:connection) {Protocol::HTTP1::Connection.new(buffer, state: :open)} let(:body) {subject.new(connection)} with "#inspect" do it "can be inspected" do expect(body.inspect).to be =~ /reading/ end end with "#as_json" do it "returns JSON representation" do expect(body.as_json).to have_keys( class: be == "Protocol::HTTP1::Body::Remainder", length: be_nil, stream: be == false, ready: be == false, empty: be == false, block_size: be == 65536, state: be == "open" ) end it "shows finished state after reading all data" do body.read # Read all available data (this will read until EOF and close connection) # Need to read again to trigger the EOF handling that closes the connection body.read # This returns nil and sets @connection = nil expect(body.as_json).to have_keys( empty: be == true, state: be == "closed" ) end end with "#empty?" do it "returns whether EOF was reached" do expect(body.empty?).to be == false end end with "#stop" do it "closes the stream" do body.close(EOFError) expect(buffer).to be(:closed?) expect(connection).to be(:half_closed_remote?) end it "closes the stream when EOF was reached" do body.read body.close(EOFError) expect(buffer).to be(:closed?) expect(connection).to be(:half_closed_remote?) end end with "#read" do it "retrieves chunks of content" do expect(body).not.to be(:empty?) expect(body.read).to be == "Hello World" expect(body.read).to be == nil expect(body).to be(:empty?) expect(connection).to be(:half_closed_remote?) end end with "#call" do it "streams the content" do stream = StringIO.new body.call(stream) expect(stream.string).to be == "Hello World" expect(connection).to be(:half_closed_remote?) end end with "#join" do it "returns all content" do expect(body).not.to be(:empty?) expect(body.join).to be == "Hello World" expect(body).to be(:empty?) expect(connection).to be(:half_closed_remote?) end end end protocol-http1-0.35.2/test/protocol/http1/connection.rb000066400000000000000000000541731506761724700230750ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. # Copyright, 2019, by Brian Morearty. # Copyright, 2020, by Bruno Sutic. # Copyright, 2024, by Thomas Morgan. require "protocol/http1/connection" require "protocol/http/body/buffered" require "protocol/http/body/writable" require "connection_context" describe Protocol::HTTP1::Connection do include_context ConnectionContext with "#read_line?" do it "reads a line from the stream" do client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n" client.stream.close expect(server.read_line?).to be == "GET / HTTP/1.1" expect(server.read_line?).to be == "Host: localhost" expect(server.read_line?).to be == "" expect(server.read_line?).to be_nil end it "raises LineLengthError if line is too long" do # We create a thread since the write is liable to block until we try to read: thread = Thread.new do client.stream.write "GET / HTTP/1.#{"1" * 10000}" client.stream.close end expect do server.read_line? end.to raise_exception(Protocol::HTTP1::LineLengthError) ensure thread.join end it "raises ProtocolError if line is not terminated properly" do client.stream.write "GET / HTTP/1.1\r" client.stream.close expect do server.read_line? end.to raise_exception(Protocol::HTTP1::ProtocolError) end it "returns nil on Errno::ECONNRESET" do expect(server.stream).to receive(:gets).and_raise(Errno::ECONNRESET) expect(server.read_line?).to be_nil end end with "#read_request" do it "reads request without body" do client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n" client.stream.close expect(server).to receive(:read_request_line) authority, method, target, version, headers, body = server.read_request expect(authority).to be == "localhost" expect(method).to be == "GET" expect(target).to be == "/" expect(version).to be == "HTTP/1.1" expect(headers).to be == {} expect(body).to be_nil expect(server).to be(:persistent) end it "reads request without body after closing connection" do client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nAccept: */*\r\nHeader-0: value 1\r\n\r\n" client.stream.close authority, method, target, version, headers, body = server.read_request expect(authority).to be == "localhost" expect(method).to be == "GET" expect(target).to be == "/" expect(version).to be == "HTTP/1.1" expect(headers).to be == {"accept" => ["*/*"], "header-0" => ["value 1"]} expect(body).to be_nil expect(server).to be(:persistent) end it "reads request with fixed body" do client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 11\r\n\r\nHello World" client.stream.close authority, method, target, version, headers, body = server.read_request expect(authority).to be == "localhost" expect(method).to be == "GET" expect(target).to be == "/" expect(version).to be == "HTTP/1.1" expect(headers).to be == {} expect(body.join).to be == "Hello World" expect(server).to be(:persistent) end it "reads request with chunked body" do client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\nb\r\nHello World\r\n0\r\n\r\n" client.stream.close authority, method, target, version, headers, body = server.read_request expect(authority).to be == "localhost" expect(method).to be == "GET" expect(target).to be == "/" expect(version).to be == "HTTP/1.1" expect(headers).to be == {} expect(body.join).to be == "Hello World" expect(server).to be(:persistent?, version, method, headers) expect(server).to be(:persistent) end it "reads request with CONNECT method" do client.stream.write "CONNECT localhost:443 HTTP/1.1\r\nHost: localhost\r\n\r\n" client.stream.close authority, method, target, version, headers, body = server.read_request expect(authority).to be == "localhost" expect(method).to be == "CONNECT" expect(target).to be == "localhost:443" expect(version).to be == "HTTP/1.1" expect(headers).to be == {} expect(body).to be_a(Protocol::HTTP1::Body::Remainder) expect(server).not.to be(:persistent?, version, method, headers) end it "fails with broken request" do client.stream.write "Accept: */*\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n" client.stream.close expect do server.read_request end.to raise_exception(Protocol::HTTP1::InvalidRequest) end it "fails with missing version" do client.stream.write "GET foo\r\n" client.stream.close expect do server.read_request end.to raise_exception(Protocol::HTTP1::InvalidRequest) end it "yields to block" do client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n" client.stream.close result = server.read_request do |authority, method, target, version, headers, body| expect(authority).to be == "localhost" expect(method).to be == "GET" expect(target).to be == "/" expect(version).to be == "HTTP/1.1" expect(headers).to be == {} expect(body).to be_nil :yielded end expect(result).to be == :yielded end end with "#write_response" do it "fails to write a response with invalid header name" do server.open! invalid_header_names = [ "foo bar", "foo:bar", "foo: bar", "foo bar:baz", 'foo\r\nbar', 'foo\nbar', 'foo\rbar', ] invalid_header_names.each do |name| expect(name).not.to be =~ Protocol::HTTP1::VALID_FIELD_NAME expect do server.write_response("HTTP/1.1", 200, {name => "baz"}, []) end.to raise_exception(Protocol::HTTP1::BadHeader) end end end with "#write_interim_response" do it "can write iterm response" do server.open! server.write_interim_response("HTTP/1.1", 100, {}) server.close expect(client.stream.read).to be == "HTTP/1.1 100 Continue\r\n\r\n" end end with "#persistent?" do describe "HTTP 1.0" do it "should not be persistent by default" do expect(server).not.to be(:persistent?, "HTTP/1.0", "GET", {}) end it "should be persistent if connection: keep-alive is set" do headers = Protocol::HTTP::Headers[ "connection" => "keep-alive" ] expect(server).to be(:persistent?, "HTTP/1.0", "GET", headers) end it "should allow case-insensitive 'connection' value" do headers = Protocol::HTTP::Headers[ "connection" => "Keep-Alive" ] expect(server).to be(:persistent?, "HTTP/1.0", "GET", headers) end end describe "HTTP 1.1" do it "should be persistent by default" do expect(server).to be(:persistent?, "HTTP/1.1", "GET", {}) end it "should not be persistent if connection: close is set" do headers = Protocol::HTTP::Headers[ "connection" => "close" ] expect(server).not.to be(:persistent?, "HTTP/1.1", "GET", headers) end it "should allow case-insensitive 'connection' value" do headers = Protocol::HTTP::Headers[ "connection" => "Close" ] expect(server).not.to be(:persistent?, "HTTP/1.1", "GET", headers) end end end with "#read_response" do it "should read successful response" do client.open! server.stream.write("HTTP/1.1 200 Hello\r\nContent-Length: 0\r\n\r\n") server.stream.close expect(client).to receive(:read_response_line) version, status, reason, headers, body = client.read_response("GET") expect(version).to be == "HTTP/1.1" expect(status).to be == 200 expect(reason).to be == "Hello" expect(headers).to be == {} expect(body).to be_nil end it "should yield to block" do client.open! server.stream.write("HTTP/1.1 200 Hello\r\nContent-Length: 0\r\n\r\n") server.stream.close result = client.read_response("GET") do |version, status, reason, headers, body| expect(version).to be == "HTTP/1.1" expect(status).to be == 200 expect(reason).to be == "Hello" expect(headers).to be == {} expect(body).to be_nil :yielded end expect(result).to be == :yielded end end with "#read_response_body" do with "GET" do it "should ignore body for informational responses" do body = client.read_response_body("GET", 100, {"content-length" => "10"}) expect(body).to be_nil expect(client.persistent).to be == true end it "should ignore body for no content responses" do expect(client.read_response_body("GET", 204, {})).to be_nil end it "should handle non-chunked transfer-encoding" do body = client.read_response_body("GET", 200, {"transfer-encoding" => ["identity"]}) expect(body).to be_a(::Protocol::HTTP1::Body::Remainder) expect(client.persistent).to be == false end it "should be an error if both transfer-encoding and content-length is set" do expect do client.read_response_body("GET", 200, {"content-length" => "10", "transfer-encoding" => ["chunked"]}) end.to raise_exception(Protocol::HTTP1::BadRequest) end end with "HEAD" do it "can read length of head response" do client.open! body = client.read_response_body("HEAD", 200, {"content-length" => "3773"}) expect(body).to be_a ::Protocol::HTTP::Body::Head expect(body.length).to be == 3773 expect(body.read).to be_nil end it "ignores zero length body" do body = client.read_response_body("HEAD", 200, {"content-length" => "0"}) expect(body).to be_nil end it "raises error if content-length is invalid" do expect do client.read_response_body("HEAD", 200, {"content-length" => "foo"}) end.to raise_exception(Protocol::HTTP1::BadRequest) end end with "CONNECT" do it "should ignore body for informational responses" do expect(client.read_response_body("CONNECT", 200, {})).to be_a(Protocol::HTTP1::Body::Remainder) end end end with "#write_chunked_body" do let(:chunks) {["Hello", "World"]} let(:body) {::Protocol::HTTP::Body::Buffered.wrap(chunks)} before do server.open! client.open! end it "can generate and read chunked response" do server.write_chunked_body(body, false) server.close headers = client.read_headers expect(headers).to be == [["transfer-encoding", "chunked"]] body = client.read_body(headers, false) expect(body.join).to be == chunks.join end it "can generate and read trailer" do chunks = ["Hello", "World"] server.write_headers({"trailer" => "etag"}) server.write_chunked_body(body, false, {"etag" => "abcd"}) server.close headers = client.read_headers expect(headers).to be == [["trailer", "etag"], ["transfer-encoding", "chunked"]] body = client.read_body(headers, false) expect(body.join).to be == chunks.join expect(headers).to have_keys("etag") end with "HEAD request" do it "can generate and read chunked response" do server.write_chunked_body(body, true) server.close headers = client.read_headers expect(headers).to be == [["transfer-encoding", "chunked"]] body = client.read_response_body("HEAD", 200, headers) expect(body).to be_nil end end end with "#write_fixed_length_body" do let(:chunks) {["Hello", "World"]} let(:body) {::Protocol::HTTP::Body::Buffered.wrap(chunks)} before do server.open! client.open! end it "can generate a valid response" do server.write_fixed_length_body(body, 10, false) server.close headers = client.read_headers expect(headers).to be == [["content-length", "10"]] body = client.read_body(headers, false) expect(body.join).to be == chunks.join end with "a length smaller than stream size" do it "raises an error" do expect do server.write_fixed_length_body(body, 100, false) end.to raise_exception(Protocol::HTTP1::ContentLengthError) end end with "a length larger than stream size" do it "raises an error" do expect do server.write_fixed_length_body(body, 1, false) end.to raise_exception(Protocol::HTTP1::ContentLengthError) end end with "HEAD request" do it "can generate a valid response" do server.write_fixed_length_body(body, 10, true) server.close headers = client.read_headers expect(headers).to be == [["content-length", "10"]] body = client.read_response_body("HEAD", 200, headers) expect(body).to be_a(Protocol::HTTP::Body::Head) expect(body.length).to be == 10 expect(body.read).to be_nil end end end with "#write_upgrade_body" do let(:body) {::Protocol::HTTP::Body::Buffered.new(["Hello ", "World!"])} before do server.open! client.open! end it "can generate and read upgrade response" do server.write_upgrade_body("text", body) server.close headers = client.read_headers expect(headers).to have_keys( "connection" => be == ["upgrade"], "upgrade" => be == ["text"] ) body = client.read_body(headers, true) expect(body.join).to be == "Hello World!" end end with "#write_tunnel_body" do let(:body) {::Protocol::HTTP::Body::Buffered.new(["Hello ", "World!"])} before do server.open! client.open! end it "can generate and read tunnel response" do server.write_tunnel_body("HTTP/1.1", body) server.close headers = client.read_headers expect(headers).to have_keys( "connection" => be == ["close"], ) body = client.read_body(headers, true) expect(body.join).to be == "Hello World!" end end with "#write_body_and_close" do let(:body) {::Protocol::HTTP::Body::Buffered.new(["Hello ", "World!"])} before do server.open! client.open! end it "can generate and write response" do server.write_body_and_close(body, false) server.close headers = client.read_headers expect(headers).to be(:empty?) body = client.read_body(headers, true) expect(body.join).to be == "Hello World!" end with "HEAD request" do it "can generate and write response" do server.write_body_and_close(body, true) server.close headers = client.read_headers expect(headers).to be(:empty?) body = client.read_response_body("HEAD", 200, headers) expect(body).to be_nil end end end with "#write_body" do let(:body) {Protocol::HTTP::Body::Buffered.new} before do server.open! end it "can write empty body" do expect(body).to receive(:empty?).and_return(true) expect(body).to receive(:length).and_return(false) expect(server).to receive(:write_empty_body) server.write_body("HTTP/1.0", body) headers = client.read_headers expect(headers).to be == [["connection", "keep-alive"], ["content-length", "0"]] body = client.read_body(headers, true) expect(body).to be_nil end it "can write nil body" do expect(server).to receive(:write_empty_body) server.write_body("HTTP/1.0", nil) server.close headers = client.read_headers expect(headers).to be == [["connection", "keep-alive"], ["content-length", "0"]] body = client.read_body(headers, true) expect(body).to be_nil end it "can write fixed length body" do expect(body).to receive(:length).and_return(1024) expect(server).to receive(:write_fixed_length_body).and_return(true) server.write_body("HTTP/1.0", body) end it "can write chunked body" do expect(server.persistent).to be == true expect(body).to receive(:empty?).and_return(false) expect(body).to receive(:length).and_return(nil) expect(server).to receive(:write_chunked_body) server.write_body("HTTP/1.1", body) end it "can write fixed length body for HTTP/1.1" do expect(body).to receive(:length).and_return(1024) expect(server).to receive(:write_fixed_length_body).and_return(true) server.write_body("HTTP/1.1", body) end it "can write closed body" do expect(server.persistent).to be == true expect(body).to receive(:empty?).and_return(false) expect(body).to receive(:length).and_return(nil) expect(server).to receive(:write_body_and_close) server.write_body("HTTP/1.0", body) end end with "bad requests" do it "should fail with negative content length" do client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: -1\r\n\r\nHello World" client.stream.close expect do server.read_request end.to raise_exception(Protocol::HTTP1::BadRequest) end it "should fail with invalid headers" do client.stream.write "GET / HTTP/1.1\r\nHost: \000localhost\r\n\r\nHello World" client.stream.close expect do server.read_request end.to raise_exception(Protocol::HTTP1::BadHeader) end end with "bad responses" do it 'should fail if headers contain \r characters' do expect do server.write_headers( [["id", "5\rSet-Cookie: foo-bar"]] ) end.to raise_exception(Protocol::HTTP1::BadHeader) end it 'should fail if headers contain \n characters' do expect do server.write_headers( [["id", "5\nSet-Cookie: foo-bar"]] ) end.to raise_exception(Protocol::HTTP1::BadHeader) end end it "enters half-closed (local) state after writing response body" do expect(client).to be(:idle?) client.write_request("localhost", "GET", "/", "HTTP/1.1", {}) expect(client).to be(:open?) body = Protocol::HTTP::Body::Buffered.new(["Hello World"]) client.write_body("HTTP/1.1", body) expect(client).to be(:half_closed_local?) expect(server).to be(:idle?) request = server.read_request server.write_response("HTTP/1.1", 200, {}, nil) server.write_body("HTTP/1.1", nil) expect(server).to be(:half_closed_local?) end it "returns back to idle state" do expect(client).to be(:idle?) client.write_request("localhost", "GET", "/", "HTTP/1.1", {}) expect(client).to be(:open?) client.write_body("HTTP/1.1", nil) expect(client).to be(:half_closed_local?) expect(server).to be(:idle?) request = server.read_request expect(request).to be == ["localhost", "GET", "/", "HTTP/1.1", {}, nil] expect(server).to be(:half_closed_remote?) server.write_response("HTTP/1.1", 200, {}, []) server.write_body("HTTP/1.1", nil) expect(server).to be(:idle?) response = client.read_response("GET") expect(client).to be(:idle?) end it "transitions to the closed state when using connection: close response body" do expect(client).to be(:idle?) client.write_request("localhost", "GET", "/", "HTTP/1.0", {}) expect(client).to be(:open?) client.write_body("HTTP/1.0", nil) expect(client).to be(:half_closed_local?) expect(server).to be(:idle?) request = server.read_request expect(server).to be(:half_closed_remote?) server.write_response("HTTP/1.0", 200, {}, []) # Length is unknown, and HTTP/1.0 does not support chunked encoding, so this will close the connection: body = Protocol::HTTP::Body::Writable.new body.write "Hello World" body.close_write server.write_body("HTTP/1.0", body) expect(server).not.to be(:persistent) expect(server).to be(:closed?) response = client.read_response("GET") body = response.last expect(body.join).to be == "Hello World" expect(client).to be(:closed?) end it "can't write a request in the closed state" do client.state = :closed expect do client.write_request("localhost", "GET", "/", "HTTP/1.0", {}) end.to raise_exception(Protocol::HTTP1::ProtocolError) end it "can't read a response in the closed state" do client.state = :closed expect do client.read_response("GET") end.to raise_exception(Protocol::HTTP1::ProtocolError) end it "can't write a response in the closed state" do server.state = :closed expect do server.write_response("HTTP/1.0", 200, {}, nil) end.to raise_exception(Protocol::HTTP1::ProtocolError) end it "can't read a request in the closed state" do server.state = :closed expect do server.read_request end.to raise_exception(Protocol::HTTP1::ProtocolError) end it "can't write response body without writing response" do expect do server.write_body("HTTP/1.0", nil) end.to raise_exception(Protocol::HTTP1::ProtocolError) end it "can't write request body without writing request" do expect do client.write_body("HTTP/1.0", nil) end.to raise_exception(Protocol::HTTP1::ProtocolError) end it "can't read request body without reading request" do # Fake empty chunked encoded body: client.stream.write("0\r\n\r\n") body = server.read_request_body("POST", {"transfer-encoding" => ["chunked"]}) expect(body).to be_a(Protocol::HTTP1::Body::Chunked) expect do body.join end.to raise_exception(Protocol::HTTP1::ProtocolError) end it "can't write interim response in the closed state" do server.state = :closed expect do server.write_interim_response("HTTP/1.0", 100, {}) end.to raise_exception(Protocol::HTTP1::ProtocolError) end with "#close" do it "enters closed state" do server.close expect(server).to be(:closed?) end it "enters closed state when given an error" do expect(server).to be(:persistent) error = Protocol::HTTP1::InvalidRequest.new("Invalid request") expect(server).to receive(:closed).with(error) server.close(error) expect(server).to be(:closed?) end it "can close the body before client is closed" do client.open! # Simulate a CONNECT request, which yields a Body::Remainder: server.stream.write("HTTP/1.1 200 Connection Established\r\n\r\n") server.stream.close version, status, reason, headers, body = client.read_response("CONNECT") expect(body).to be_a(Protocol::HTTP1::Body::Remainder) # Close the body before closing the client: body.close # Now, close the client: client.close end it "allows closing remainder body after client is closed" do client.open! # Simulate a CONNECT request, which yields a Body::Remainder: server.stream.write("HTTP/1.1 200 Connection Established\r\n\r\n") server.stream.close version, status, reason, headers, body = client.read_response("CONNECT") expect(body).to be_a(Protocol::HTTP1::Body::Remainder) # Close the client before reading/closing the body: client.close # Now, close the body, which should not raise: body.close end end end protocol-http1-0.35.2/test/protocol/http1/connection/000077500000000000000000000000001506761724700225365ustar00rootroot00000000000000protocol-http1-0.35.2/test/protocol/http1/connection/bad.rb000066400000000000000000000061321506761724700236130ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2024, by Samuel Williams. require "protocol/http1/connection" require "connection_context" describe Protocol::HTTP1::Connection do include_context ConnectionContext before do # We use a thread here, as writing to the stream may block, e.g. if the input is big enough. @writer = Thread.new do client.stream.write(input) client.stream.close end end after do @writer.join end with "invalid hexadecimal content-length" do def input <<~HTTP.gsub("\n", "\r\n") POST / HTTP/1.1 Host: a.com Content-Length: 0x10 Connection: close 0123456789abcdef HTTP end it "should fail to parse the request body" do expect do server.read_request end.to raise_exception(Protocol::HTTP1::BadRequest) end end with "invalid +integer content-length" do def input <<~HTTP.gsub("\n", "\r\n") POST / HTTP/1.1 Host: a.com Content-Length: +16 Connection: close 0123456789abcdef HTTP end it "should fail to parse the request body" do expect do server.read_request end.to raise_exception(Protocol::HTTP1::BadRequest) end end with "invalid -integer content-length" do def input <<~HTTP.gsub("\n", "\r\n") POST / HTTP/1.1 Host: a.com Content-Length: -16 Connection: close 0123456789abcdef HTTP end it "should fail to parse the request body" do expect do server.read_request end.to raise_exception(Protocol::HTTP1::BadRequest) end end with "invalid hexidecimal chunk size" do def input <<~HTTP.gsub("\n", "\r\n") POST / HTTP/1.1 Host: a.com Transfer-Encoding: chunked Connection: close 0x10 0123456789abcdef 0 HTTP end it "should fail to parse the request body" do authority, method, target, version, headers, body = server.read_request expect(body).to be_a(Protocol::HTTP1::Body::Chunked) expect do body.read end.to raise_exception(Protocol::HTTP1::BadRequest) end end with "invalid +integer chunk size" do def input <<~HTTP.gsub("\n", "\r\n") POST / HTTP/1.1 Host: a.com Transfer-Encoding: chunked Connection: close +10 0123456789abcdef 0 HTTP end it "should fail to parse the request body" do authority, method, target, version, headers, body = server.read_request expect(body).to be_a(Protocol::HTTP1::Body::Chunked) expect do body.read end.to raise_exception(Protocol::HTTP1::BadRequest) end end with "line length exceeding the limit" do def input <<~HTTP.gsub("\n", "\r\n") POST / HTTP/1.1 Host: a.com Connection: close Long-Header: #{'a' * 8192} HTTP end it "should fail to parse the request" do expect do server.read_request end.to raise_exception(Protocol::HTTP1::LineLengthError) end end with "incomplete headers" do def input <<~HTTP.gsub("\n", "\r\n") POST / HTTP/1.1 Host: a.com HTTP end it "should fail to parse the request" do expect do server.read_request end.to raise_exception(EOFError) end end end protocol-http1-0.35.2/test/protocol/http1/connection/headers.rb000066400000000000000000000123351506761724700245020ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "protocol/http1/connection" require "connection_context" describe Protocol::HTTP1::Connection do include_context ConnectionContext with "#write_response" do before do server.open! end def validate_headers!(expected_headers = self.headers) server.write_empty_body client.open! version, status, reason, headers, body = client.read_response("GET") expect(headers).to be == headers end with "a content-type header" do let(:headers) {{"content-type" => "text/plain"}} it "can parse the header" do server.write_response("HTTP/1.1", 200, headers) validate_headers! end end with "an empty header" do let(:headers) {{"nothing" => ""}} it "can parse the header" do server.write_response("HTTP/1.1", 200, headers) validate_headers! end end with "a header that contains tab characters" do let(:headers) {{"user-agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_4) AppleWebKit/537.36 (KHTML, like Gecko) \t\t\tChrome/55.0.2883.95 Safari/537.36"}} it "can parse the header" do server.write_response("HTTP/1.1", 200, headers) validate_headers! end end with "a header that contains obsolete folding whitespace" do let(:headers) {{"user-agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_4) AppleWebKit/537.36 (KHTML, like Gecko)\n\tChrome/55.0.2883.95 Safari/537.36"}} it "rejects the response" do expect do server.write_response("HTTP/1.1", 200, headers) end.to raise_exception(Protocol::HTTP1::BadHeader) end end with "a header that contains invalid characters" do let(:headers) {{"user-agent" => "Mozilla\x00Hacker Browser"}} it "rejects the response" do expect do server.write_response("HTTP/1.1", 200, headers) end.to raise_exception(Protocol::HTTP1::BadHeader) end end with "a header that contains invalid high characters" do let(:headers) {{"user-agent" => "Mozilla\x7FHacker Browser"}} it "allows the response" do server.write_response("HTTP/1.1", 200, headers) validate_headers! end end end with "#read_request" do let(:headers) {Array.new} before do client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\n#{headers.join("\r\n")}\r\n\r\n" client.stream.close end with "a header that contains tab characters" do let(:headers) {[ "user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_4) AppleWebKit/537.36 (KHTML, like Gecko) \t\t\tChrome/55.0.2883.95 Safari/537.36" ]} it "can parse the header" do authority, method, target, version, headers, body = server.read_request expect(headers).to have_keys( "user-agent" => be == "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_4) AppleWebKit/537.36 (KHTML, like Gecko) \t\t\tChrome/55.0.2883.95 Safari/537.36" ) end end with "a header that contains obsolete folding whitespace" do let(:headers) {[ "user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_4) AppleWebKit/537.36 (KHTML, like Gecko)\n\tChrome/55.0.2883.95 Safari/537.36" ]} it "rejects the request" do expect do server.read_request end.to raise_exception(Protocol::HTTP1::BadHeader) end end with "a header that contains invalid characters" do let(:headers) {[ "user-agent: Mozilla\x00Hacker Browser" ]} it "rejects the request" do expect do server.read_request end.to raise_exception(Protocol::HTTP1::BadHeader) end end with "a header that contains invalid high characters" do let(:headers) {[ "user-agent: Mozilla\x7FHacker Browser" ]} it "allows the request" do authority, method, target, version, headers, body = server.read_request expect(headers).to have_keys( "user-agent" => be == "Mozilla\x7FHacker Browser" ) end end with "a header that contains null character" do let(:headers) {[ "user-agent: Mozilla\x00Hacker Browser" ]} it "rejects the request" do expect do server.read_request end.to raise_exception(Protocol::HTTP1::BadHeader) end end with "a header that has empty value but includes optional whitespace" do let(:headers) {[ "user-agent: " ]} it "can parse the header" do authority, method, target, version, headers, body = server.read_request expect(headers).to have_keys( "user-agent" => be == "" ) end end with "a header that has empty value" do let(:headers) {[ "user-agent:" ]} it "can parse the header" do authority, method, target, version, headers, body = server.read_request expect(headers).to have_keys( "user-agent" => be == "" ) end end with "a header that has invalid name" do let(:headers) {[ "invalid name: value" ]} it "rejects the request" do expect do server.read_request end.to raise_exception(Protocol::HTTP1::BadHeader) end end with "a header that has empty name" do let(:headers) {[ ": value" ]} it "rejects the request" do expect do server.read_request end.to raise_exception(Protocol::HTTP1::BadHeader) end end end end protocol-http1-0.35.2/test/protocol/http1/connection/persistent.rb000066400000000000000000000106301506761724700252630ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "protocol/http1/connection" require "protocol/http/body/buffered" require "protocol/http/body/writable" require "connection_context" describe Protocol::HTTP1::Connection do include_context ConnectionContext with "multiple requests in a single connection" do it "handles two back-to-back GET requests (HTTP/1.1 keep-alive)" do client.write_request("localhost", "GET", "/first", "HTTP/1.1", {"Header-A" => "Value-A"}) client.write_body("HTTP/1.1", nil) expect(client).to be(:half_closed_local?) # Server reads it: authority, method, path, version, headers, body = server.read_request expect(authority).to be == "localhost" expect(method).to be == "GET" expect(path).to be == "/first" expect(version).to be == "HTTP/1.1" expect(headers["header-a"]).to be == ["Value-A"] expect(body).to be_nil # Server writes a response: expect(server).to be(:half_closed_remote?) server.write_response("HTTP/1.1", 200, {"Res-A" => "ValA"}, "OK") server.write_body("HTTP/1.1", nil) expect(server).to be(:idle?) # Client reads first response: version, status, reason, headers, body = client.read_response("GET") expect(version).to be == "HTTP/1.1" expect(status).to be == 200 expect(reason).to be == "OK" expect(headers["res-a"]).to be == ["ValA"] expect(body).to be_nil # Now both sides should be back to :idle (persistent re-use): expect(client).to be(:idle?) expect(server).to be(:idle?) # Second request: client.write_request("localhost", "GET", "/second", "HTTP/1.1", {"Header-B" => "Value-B"}) client.write_body("HTTP/1.1", nil) expect(client).to be(:half_closed_local?) # Server reads it: authority, method, path, version, headers, body = server.read_request expect(authority).to be == "localhost" expect(method).to be == "GET" expect(path).to be == "/second" expect(version).to be == "HTTP/1.1" expect(headers["header-b"]).to be == ["Value-B"] expect(body).to be_nil # Server writes a response: expect(server).to be(:half_closed_remote?) server.write_response("HTTP/1.1", 200, {"Res-B" => "ValB"}, "OK") server.write_body("HTTP/1.1", nil) # Client reads second response: version, status, reason, headers, body = client.read_response("GET") expect(version).to be == "HTTP/1.1" expect(status).to be == 200 expect(reason).to be == "OK" expect(headers["res-b"]).to be == ["ValB"] expect(body).to be_nil # Confirm final states: expect(client).to be(:idle?) expect(server).to be(:idle?) end end with "partial body read" do it "closes correctly if server does not consume entire fixed-length body" do # Indicate Content-Length = 11 but only read part of it on server side: client.stream.write "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 11\r\n\r\nHello" client.stream.close # Server reads request line/headers: authority, method, path, version, headers, body = server.read_request expect(method).to be == "POST" expect(body).to be_a(Protocol::HTTP1::Body::Fixed) # Partially read 5 bytes only: partial = body.read expect(partial).to be == "Hello" expect do body.read end.to raise_exception(EOFError) # Then server forcibly closes read (simulating a deliberate stop): server.close_read # Because of partial consumption, that should move the state to half-closed remote or closed, etc. expect(server).to be(:half_closed_remote?) expect(server).not.to be(:persistent) end end with "no persistence" do it "closes connection after request" do server.persistent = false client.write_request("localhost", "GET", "/first", "HTTP/1.1", {"Header-A" => "Value-A"}) client.write_body("HTTP/1.1", nil) expect(client).to be(:half_closed_local?) # Server reads it: authority, method, path, version, headers, body = server.read_request expect(authority).to be == "localhost" expect(method).to be == "GET" expect(path).to be == "/first" expect(version).to be == "HTTP/1.1" expect(headers["header-a"]).to be == ["Value-A"] expect(body).to be_nil # Server writes a response: expect(server).to be(:half_closed_remote?) server.write_response("HTTP/1.1", 200, {"Res-A" => "ValA"}, "OK") server.write_body("HTTP/1.1", nil) expect(server).to be(:closed?) end end end protocol-http1-0.35.2/test/protocol/http1/hijack.rb000066400000000000000000000031661506761724700221630ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. # Copyright, 2024, by Anton Zhuravsky. # Copyright, 2024, by Thomas Morgan. require "protocol/http1/connection" require "protocol/http/body/buffered" require "connection_context" describe Protocol::HTTP1::Connection do include_context ConnectionContext with "#hijack" do let(:response_version) {Protocol::HTTP1::Connection::HTTP10} let(:body) {Protocol::HTTP::Body::Buffered.new} let(:text) {"Hello World!"} it "should not be persistent after hijack" do server_wrapper = server.hijack! expect(server.persistent).to be == false end it "should repord itself as #hijacked? after the hijack" do expect(server.hijacked?).to be == false server.hijack! expect(server.hijacked?).to be == true end it "should use non-chunked output" do server.open! expect(body).to receive(:ready?).and_return(false) expect(body).to receive(:each).and_return(nil) server.write_response(response_version, 101, {"upgrade" => "websocket"}) server.write_body(response_version, body) server_stream = server.hijack! client.open! version, status, reason, headers, body = client.read_response("GET") expect(version).to be == response_version expect(status).to be == 101 expect(headers).to have_keys( "upgrade" => be == ["websocket"], ) expect(body).to be_a(::Protocol::HTTP1::Body::Remainder) # due to 101 status client_stream = client.hijack! client_stream.write(text) client_stream.close expect(server_stream.read).to be == text end end end protocol-http1-0.35.2/test/protocol/http1/parser.rb000066400000000000000000000020261506761724700222200ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "protocol/http1/connection" describe Protocol::HTTP1 do describe "REQUEST_LINE" do it "parses in linear time" do skip_unless_method_defined(:linear_time?, Regexp.singleton_class) expect(Regexp).to be(:linear_time?, Protocol::HTTP1::REQUEST_LINE) end end describe "HEADER" do it "parses in linear time" do skip_unless_method_defined(:linear_time?, Regexp.singleton_class) expect(Regexp).to be(:linear_time?, Protocol::HTTP1::HEADER) end end describe "VALID_FIELD_NAME" do it "parses in linear time" do skip_unless_method_defined(:linear_time?, Regexp.singleton_class) expect(Regexp).to be(:linear_time?, Protocol::HTTP1::VALID_FIELD_NAME) end end describe "VALID_FIELD_VALUE" do it "parses in linear time" do skip_unless_method_defined(:linear_time?, Regexp.singleton_class) expect(Regexp).to be(:linear_time?, Protocol::HTTP1::VALID_FIELD_VALUE) end end end protocol-http1-0.35.2/test/protocol/http1/trailer.rb000066400000000000000000000026141506761724700223710ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2022-2024, by Samuel Williams. require "protocol/http1/connection" require "protocol/http/body/buffered" require "connection_context" describe Protocol::HTTP1::Connection do include_context ConnectionContext let(:chunks) {["Hello", "World"]} let(:body) {::Protocol::HTTP::Body::Buffered.wrap(chunks)} let(:trailer) {Hash.new} with "trailers" do before do client.open! server.open! end it "ignores trailers with HTTP/1.0" do expect(server).to receive(:write_fixed_length_body) server.write_body("HTTP/1.0", body, false, trailer) end it "ignores trailers with content length" do expect(server).to receive(:write_fixed_length_body) server.write_body("HTTP/1.1", body, false, trailer) end it "uses chunked encoding when given trailers without content length" do expect(body).to receive(:length).and_return(nil) trailer["foo"] = "bar" server.write_response("HTTP/1.1", 200, {}) server.write_body("HTTP/1.1", body, false, trailer) version, status, reason, headers, body = client.read_response("GET") expect(version).to be == "HTTP/1.1" expect(status).to be == 200 expect(headers).to be == {} # Read all of the response body, including trailers: body.join # Headers are updated: expect(headers).to be == {"foo" => ["bar"]} end end end protocol-http1-0.35.2/test/protocol/http1/upgrade.rb000066400000000000000000000016071506761724700223570ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2024, by Samuel Williams. require "protocol/http1/connection" require "connection_context" describe Protocol::HTTP1::Connection do include_context ConnectionContext with "#upgrade" do let(:protocol) {"binary"} let(:request_version) {Protocol::HTTP1::Connection::HTTP10} it "should upgrade connection" do client.write_request("testing.com", "GET", "/", request_version, []) stream = client.write_upgrade_body(protocol) stream.write "Hello World" stream.close_write authority, method, path, version, headers, body = server.read_request expect(version).to be == request_version expect(headers["upgrade"]).to be == [protocol] expect(body).to be_a(Protocol::HTTP1::Body::Remainder) stream = server.hijack! expect(stream.read).to be == "Hello World" end end end