From b96398f3ebeda79f1baea7e14b171d73e62cbe50 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 19 Sep 2025 14:38:44 +1200 Subject: [PATCH 01/14] Initial allow by default trailers implementation. --- lib/protocol/http/error.rb | 12 ++ lib/protocol/http/header/accept.rb | 4 + lib/protocol/http/header/authorization.rb | 4 + lib/protocol/http/header/connection.rb | 4 + lib/protocol/http/header/cookie.rb | 4 + lib/protocol/http/header/date.rb | 4 + lib/protocol/http/header/etag.rb | 4 + lib/protocol/http/header/multiple.rb | 4 + lib/protocol/http/header/split.rb | 4 + lib/protocol/http/header/te.rb | 128 +++++++++++++++++ lib/protocol/http/header/trailer.rb | 21 +++ lib/protocol/http/header/transfer_encoding.rb | 75 ++++++++++ lib/protocol/http/headers.rb | 44 +++++- test/protocol/http/header/multiple.rb | 6 + test/protocol/http/header/te.rb | 134 ++++++++++++++++++ test/protocol/http/header/trailer.rb | 76 ++++++++++ .../protocol/http/header/transfer_encoding.rb | 77 ++++++++++ test/protocol/http/headers.rb | 62 ++++++++ 18 files changed, 661 insertions(+), 6 deletions(-) create mode 100644 lib/protocol/http/header/te.rb create mode 100644 lib/protocol/http/header/trailer.rb create mode 100644 lib/protocol/http/header/transfer_encoding.rb create mode 100644 test/protocol/http/header/te.rb create mode 100644 test/protocol/http/header/trailer.rb create mode 100644 test/protocol/http/header/transfer_encoding.rb diff --git a/lib/protocol/http/error.rb b/lib/protocol/http/error.rb index 0e9e05e7..c4044647 100644 --- a/lib/protocol/http/error.rb +++ b/lib/protocol/http/error.rb @@ -26,5 +26,17 @@ def initialize(key) # @attribute [String] key The header key that was duplicated. attr :key end + + class ForbiddenTrailerError < Error + include BadRequest + + # @parameter key [String] The header key that was forbidden in trailers. + def initialize(key) + super("#{key} is forbidden in trailers!") + end + + # @attribute [String] key The header key that was forbidden in trailers. + attr :key + end end end diff --git a/lib/protocol/http/header/accept.rb b/lib/protocol/http/header/accept.rb index fa140743..f1847ce8 100644 --- a/lib/protocol/http/header/accept.rb +++ b/lib/protocol/http/header/accept.rb @@ -92,6 +92,10 @@ def to_s join(",") end + def self.trailer_forbidden? + false + end + # Parse the `accept` header. # # @returns [Array(Charset)] the list of content types and their associated parameters. diff --git a/lib/protocol/http/header/authorization.rb b/lib/protocol/http/header/authorization.rb index 37c07be0..e8380b02 100644 --- a/lib/protocol/http/header/authorization.rb +++ b/lib/protocol/http/header/authorization.rb @@ -34,6 +34,10 @@ def self.basic(username, password) "Basic #{strict_base64_encoded}" ) end + + def self.trailer_forbidden? + true + end end end end diff --git a/lib/protocol/http/header/connection.rb b/lib/protocol/http/header/connection.rb index 0b5c9f58..02036b5d 100644 --- a/lib/protocol/http/header/connection.rb +++ b/lib/protocol/http/header/connection.rb @@ -50,6 +50,10 @@ def close? def upgrade? self.include?(UPGRADE) end + + def self.trailer_forbidden? + true + end end end end diff --git a/lib/protocol/http/header/cookie.rb b/lib/protocol/http/header/cookie.rb index 547047ac..f9052c5e 100644 --- a/lib/protocol/http/header/cookie.rb +++ b/lib/protocol/http/header/cookie.rb @@ -23,6 +23,10 @@ def to_h cookies.map{|cookie| [cookie.name, cookie]}.to_h end + + def self.trailer_forbidden? + true + end end # The `set-cookie` header sends cookies from the server to the user agent. diff --git a/lib/protocol/http/header/date.rb b/lib/protocol/http/header/date.rb index 86a751f4..1b7b7774 100644 --- a/lib/protocol/http/header/date.rb +++ b/lib/protocol/http/header/date.rb @@ -25,6 +25,10 @@ def << value def to_time ::Time.parse(self) end + + def self.trailer_forbidden? + false + end end end end diff --git a/lib/protocol/http/header/etag.rb b/lib/protocol/http/header/etag.rb index 8c72e467..1bfbc3a9 100644 --- a/lib/protocol/http/header/etag.rb +++ b/lib/protocol/http/header/etag.rb @@ -25,6 +25,10 @@ def << value def weak? self.start_with?("W/") end + + def self.trailer_forbidden? + false + end end end end diff --git a/lib/protocol/http/header/multiple.rb b/lib/protocol/http/header/multiple.rb index fe731f89..49b0961a 100644 --- a/lib/protocol/http/header/multiple.rb +++ b/lib/protocol/http/header/multiple.rb @@ -25,6 +25,10 @@ def initialize(value) def to_s join("\n") end + + def self.trailer_forbidden? + false + end end end end diff --git a/lib/protocol/http/header/split.rb b/lib/protocol/http/header/split.rb index 4ecb1f18..6292dca7 100644 --- a/lib/protocol/http/header/split.rb +++ b/lib/protocol/http/header/split.rb @@ -40,6 +40,10 @@ def to_s join(",") end + def self.trailer_forbidden? + false + end + protected def reverse_find(&block) diff --git a/lib/protocol/http/header/te.rb b/lib/protocol/http/header/te.rb new file mode 100644 index 00000000..83688b85 --- /dev/null +++ b/lib/protocol/http/header/te.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require_relative "split" +require_relative "quoted_string" +require_relative "../error" + +module Protocol + module HTTP + module Header + # The `te` header indicates the transfer encodings the client is willing to accept. + # + # The `te` header allows a client to indicate which transfer encodings it can handle, and in what order of preference using quality factors. + class TE < Split + ParseError = Class.new(Error) + + # Transfer encoding token pattern + TOKEN = /[!#$%&'*+\-.0-9A-Z^_`a-z|~]+/ + + # Quality value pattern (0.0 to 1.0) + QVALUE = /0(\.[0-9]{0,3})?|1(\.[0]{0,3})?/ + + # Pattern for parsing transfer encoding with optional quality factor + TRANSFER_CODING = /\A(?#{TOKEN})(\s*;\s*q=(?#{QVALUE}))?\z/ + + # The `chunked` transfer encoding + CHUNKED = "chunked" + + # The `gzip` transfer encoding + GZIP = "gzip" + + # The `deflate` transfer encoding + DEFLATE = "deflate" + + # The `compress` transfer encoding + COMPRESS = "compress" + + # The `identity` transfer encoding + IDENTITY = "identity" + + # The `trailers` pseudo-encoding indicates willingness to accept trailer fields + TRAILERS = "trailers" + + # A single transfer coding entry with optional quality factor + TransferCoding = Struct.new(:name, :q) do + def quality_factor + (q || 1.0).to_f + end + + def <=> other + other.quality_factor <=> self.quality_factor + end + + def to_s + if q && q != 1.0 + "#{name};q=#{q}" + else + name.to_s + end + end + end + + # Initializes the TE header with the given value. The value is split into distinct entries and converted to lowercase for normalization. + # + # @parameter value [String | Nil] the raw header value containing transfer encodings separated by commas. + def initialize(value = nil) + super(value&.downcase) + end + + # Adds one or more comma-separated values to the TE header. The values are converted to lowercase for normalization. + # + # @parameter value [String] the value or values to add, separated by commas. + def << value + super(value.downcase) + end + + # Parse the `te` header value into a list of transfer codings with quality factors. + # + # @returns [Array(TransferCoding)] the list of transfer codings and their associated quality factors. + def transfer_codings + self.map do |value| + if match = value.match(TRANSFER_CODING) + TransferCoding.new(match[:name], match[:q]) + else + raise ParseError.new("Could not parse transfer coding: #{value.inspect}") + end + end + end + + # @returns [Boolean] whether the `chunked` encoding is accepted. + def chunked? + self.any? {|value| value.start_with?(CHUNKED)} + end + + # @returns [Boolean] whether the `gzip` encoding is accepted. + def gzip? + self.any? {|value| value.start_with?(GZIP)} + end + + # @returns [Boolean] whether the `deflate` encoding is accepted. + def deflate? + self.any? {|value| value.start_with?(DEFLATE)} + end + + # @returns [Boolean] whether the `compress` encoding is accepted. + def compress? + self.any? {|value| value.start_with?(COMPRESS)} + end + + # @returns [Boolean] whether the `identity` encoding is accepted. + def identity? + self.any? {|value| value.start_with?(IDENTITY)} + end + + # @returns [Boolean] whether trailers are accepted. + def trailers? + self.any? {|value| value.start_with?(TRAILERS)} + end + + def self.trailer_forbidden? + true + end + end + end + end +end \ No newline at end of file diff --git a/lib/protocol/http/header/trailer.rb b/lib/protocol/http/header/trailer.rb new file mode 100644 index 00000000..ac9a4c84 --- /dev/null +++ b/lib/protocol/http/header/trailer.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2019-2025, by Samuel Williams. + +require_relative "split" + +module Protocol + module HTTP + module Header + # Represents headers that can contain multiple distinct values separated by commas. + # + # This isn't a specific header class is a utility for handling headers with comma-separated values, such as `accept`, `cache-control`, and other similar headers. The values are split and stored as an array internally, and serialized back to a comma-separated string when needed. + class Trailer < Split + def self.trailer_forbidden? + true + end + end + end + end +end diff --git a/lib/protocol/http/header/transfer_encoding.rb b/lib/protocol/http/header/transfer_encoding.rb new file mode 100644 index 00000000..98d931e4 --- /dev/null +++ b/lib/protocol/http/header/transfer_encoding.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require_relative "split" + +module Protocol + module HTTP + module Header + # The `transfer-encoding` header indicates the encoding transformations that have been applied to the message body. + # + # The `transfer-encoding` header is used to specify the form of encoding used to safely transfer the message body between the sender and receiver. + class TransferEncoding < Split + # The `chunked` transfer encoding allows a server to send data of unknown length by breaking it into chunks. + CHUNKED = "chunked" + + # The `gzip` transfer encoding compresses the message body using the gzip algorithm. + GZIP = "gzip" + + # The `deflate` transfer encoding compresses the message body using the deflate algorithm. + DEFLATE = "deflate" + + # The `compress` transfer encoding compresses the message body using the compress algorithm. + COMPRESS = "compress" + + # The `identity` transfer encoding indicates no transformation has been applied. + IDENTITY = "identity" + + # Initializes the transfer encoding header with the given value. The value is split into distinct entries and converted to lowercase for normalization. + # + # @parameter value [String | Nil] the raw header value containing transfer encodings separated by commas. + def initialize(value = nil) + super(value&.downcase) + end + + # Adds one or more comma-separated values to the transfer encoding header. The values are converted to lowercase for normalization. + # + # @parameter value [String] the value or values to add, separated by commas. + def << value + super(value.downcase) + end + + # @returns [Boolean] whether the `chunked` encoding is present. + def chunked? + self.include?(CHUNKED) + end + + # @returns [Boolean] whether the `gzip` encoding is present. + def gzip? + self.include?(GZIP) + end + + # @returns [Boolean] whether the `deflate` encoding is present. + def deflate? + self.include?(DEFLATE) + end + + # @returns [Boolean] whether the `compress` encoding is present. + def compress? + self.include?(COMPRESS) + end + + # @returns [Boolean] whether the `identity` encoding is present. + def identity? + self.include?(IDENTITY) + end + + def self.trailer_forbidden? + true + end + end + end + end +end diff --git a/lib/protocol/http/headers.rb b/lib/protocol/http/headers.rb index 00804585..b48285ab 100644 --- a/lib/protocol/http/headers.rb +++ b/lib/protocol/http/headers.rb @@ -17,11 +17,14 @@ require_relative "header/authorization" require_relative "header/date" require_relative "header/priority" +require_relative "header/trailer" require_relative "header/accept" require_relative "header/accept_charset" require_relative "header/accept_encoding" require_relative "header/accept_language" +require_relative "header/transfer_encoding" +require_relative "header/te" module Protocol module HTTP @@ -244,23 +247,41 @@ def merge(headers) self.dup.merge!(headers) end + # Singleton header policy that forbids trailers (for message framing headers) + class TrailerForbidden + def self.new(value) + value + end + + def self.trailer_forbidden? + true + end + end + # The policy for various headers, including how they are merged and normalized. POLICY = { # Headers which may only be specified once: "content-disposition" => false, - "content-length" => false, + "content-length" => TrailerForbidden, "content-type" => false, + "expect" => TrailerForbidden, "from" => false, - "host" => false, + "host" => TrailerForbidden, "location" => false, "max-forwards" => false, + "range" => false, "referer" => false, "retry-after" => false, + "server" => false, + "transfer-encoding" => Header::TransferEncoding, + "upgrade" => TrailerForbidden, "user-agent" => false, + "trailer" => Header::Trailer, # Custom headers: "connection" => Header::Connection, "cache-control" => Header::CacheControl, + "te" => Header::TE, "vary" => Header::Vary, "priority" => Header::Priority, @@ -334,8 +355,13 @@ def delete(key) # @parameter hash [Hash] The hash to merge into. # @parameter key [String] The header key. # @parameter value [String] The raw header value. - protected def merge_into(hash, key, value) + protected def merge_into(hash, key, value, trailer = @tail) if policy = POLICY[key] + # Check if we're adding to trailers and this header is forbidden + if trailer && policy.trailer_forbidden? + raise ForbiddenTrailerError, key + end + if current_value = hash[key] current_value << value else @@ -362,11 +388,17 @@ def [] key # # @returns [Hash] A hash table of `{key, value}` pairs. def to_h - @indexed ||= @fields.inject({}) do |hash, (key, value)| - merge_into(hash, key.downcase, value) + unless @indexed + @indexed = {} - hash + @fields.each_with_index do |(key, value), index| + trailer = (@tail && index >= @tail) + + merge_into(@indexed, key.downcase, value, trailer) + end end + + return @indexed end alias as_json to_h diff --git a/test/protocol/http/header/multiple.rb b/test/protocol/http/header/multiple.rb index 35a85f72..427c45bf 100644 --- a/test/protocol/http/header/multiple.rb +++ b/test/protocol/http/header/multiple.rb @@ -19,4 +19,10 @@ ) end end + + with ".trailer_forbidden?" do + it "is allowed in trailers by default" do + expect(subject).not.to be(:trailer_forbidden?) + end + end end diff --git a/test/protocol/http/header/te.rb b/test/protocol/http/header/te.rb new file mode 100644 index 00000000..f92df7d1 --- /dev/null +++ b/test/protocol/http/header/te.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "protocol/http/header/te" + +describe Protocol::HTTP::Header::TE do + let(:header) {subject.new(description)} + + with "chunked" do + it "detects chunked encoding" do + expect(header).to be(:chunked?) + end + end + + with "gzip" do + it "detects gzip encoding" do + expect(header).to be(:gzip?) + end + end + + with "deflate" do + it "detects deflate encoding" do + expect(header).to be(:deflate?) + end + end + + with "trailers" do + it "detects trailers acceptance" do + expect(header).to be(:trailers?) + end + end + + with "compress" do + it "detects compress encoding" do + expect(header).to be(:compress?) + end + end + + with "identity" do + it "detects identity encoding" do + expect(header).to be(:identity?) + end + end + + with "gzip;q=0.8, chunked;q=1.0" do + it "parses quality factors" do + codings = header.transfer_codings + expect(codings.length).to be == 2 + expect(codings[0].name).to be == "gzip" + expect(codings[0].quality_factor).to be == 0.8 + expect(codings[1].name).to be == "chunked" + expect(codings[1].quality_factor).to be == 1.0 + end + + it "contains expected encodings" do + expect(header).to be(:gzip?) + expect(header).to be(:chunked?) + end + end + + with "gzip;q=0.5, deflate;q=0.8" do + it "handles multiple quality factors" do + codings = header.transfer_codings.sort + expect(codings[0].name).to be == "deflate" # higher quality first + expect(codings[1].name).to be == "gzip" + end + end + + with "" do + let(:header) {subject.new} + + it "handles empty TE header" do + expect(header).to be(:empty?) + expect(header).not.to be(:chunked?) + end + end + + with "#<<" do + let(:header) {subject.new} + + it "can add encodings" do + header << "gzip" + expect(header).to be(:gzip?) + + header << "chunked;q=0.9" + expect(header).to be(:chunked?) + end + end + + with "error handling" do + it "raises ParseError for invalid transfer coding" do + header = subject.new("invalid@encoding") + expect do + header.transfer_codings + end.to raise_exception(Protocol::HTTP::Header::TE::ParseError) + end + end + + with "TransferCoding struct" do + it "handles quality factor conversion" do + coding = Protocol::HTTP::Header::TE::TransferCoding.new("gzip", "0.8") + expect(coding.quality_factor).to be == 0.8 + end + + it "defaults quality factor to 1.0" do + coding = Protocol::HTTP::Header::TE::TransferCoding.new("gzip", nil) + expect(coding.quality_factor).to be == 1.0 + end + + it "serializes with quality factor" do + coding = Protocol::HTTP::Header::TE::TransferCoding.new("gzip", "0.8") + expect(coding.to_s).to be == "gzip;q=0.8" + end + + it "serializes without quality factor when 1.0" do + coding = Protocol::HTTP::Header::TE::TransferCoding.new("gzip", nil) + expect(coding.to_s).to be == "gzip" + end + + it "compares by quality factor" do + high = Protocol::HTTP::Header::TE::TransferCoding.new("gzip", "0.9") + low = Protocol::HTTP::Header::TE::TransferCoding.new("deflate", "0.5") + expect(high <=> low).to be == -1 # high quality first + end + end + + with ".trailer_forbidden?" do + it "should be forbidden in trailers" do + expect(subject.trailer_forbidden?).to be == true + end + end +end \ No newline at end of file diff --git a/test/protocol/http/header/trailer.rb b/test/protocol/http/header/trailer.rb new file mode 100644 index 00000000..090e54e3 --- /dev/null +++ b/test/protocol/http/header/trailer.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "protocol/http/header/trailer" + +describe Protocol::HTTP::Header::Trailer do + let(:header) {subject.new(description)} + + with "etag" do + it "contains etag header" do + expect(header).to be(:include?, "etag") + end + + it "has one header" do + expect(header.length).to be == 1 + end + end + + with "etag, content-md5" do + it "contains multiple headers" do + expect(header).to be(:include?, "etag") + expect(header).to be(:include?, "content-md5") + end + + it "has correct count" do + expect(header.length).to be == 2 + end + end + + with "etag, content-md5, expires" do + it "handles three headers" do + expect(header).to be(:include?, "etag") + expect(header).to be(:include?, "content-md5") + expect(header).to be(:include?, "expires") + end + + it "serializes correctly" do + expect(header.to_s).to be == "etag,content-md5,expires" + end + end + + with "etag , content-md5 , expires" do + it "strips whitespace" do + expect(header.length).to be == 3 + expect(header).to be(:include?, "etag") + expect(header).to be(:include?, "content-md5") + end + end + + with "" do + let(:header) {subject.new} + + it "handles empty trailer" do + expect(header).to be(:empty?) + expect(header.to_s).to be == "" + end + end + + with "#<<" do + let(:header) {subject.new("etag")} + + it "can add headers" do + header << "content-md5, expires" + expect(header.length).to be == 3 + expect(header).to be(:include?, "expires") + end + end + + with ".trailer_forbidden?" do + it "should be forbidden in trailers" do + expect(subject.trailer_forbidden?).to be == true + end + end +end \ No newline at end of file diff --git a/test/protocol/http/header/transfer_encoding.rb b/test/protocol/http/header/transfer_encoding.rb new file mode 100644 index 00000000..c5517e4b --- /dev/null +++ b/test/protocol/http/header/transfer_encoding.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "protocol/http/header/transfer_encoding" + +describe Protocol::HTTP::Header::TransferEncoding do + let(:header) {subject.new(description)} + + with "chunked" do + it "detects chunked encoding" do + expect(header).to be(:chunked?) + end + end + + with "gzip" do + it "detects gzip encoding" do + expect(header).to be(:gzip?) + end + end + + with "deflate" do + it "detects deflate encoding" do + expect(header).to be(:deflate?) + end + end + + with "compress" do + it "detects compress encoding" do + expect(header).to be(:compress?) + end + end + + with "identity" do + it "detects identity encoding" do + expect(header).to be(:identity?) + end + end + + with "gzip, chunked" do + it "handles multiple encodings" do + expect(header.length).to be == 2 + expect(header).to be(:include?, "gzip") + expect(header).to be(:include?, "chunked") + expect(header).to be(:gzip?) + expect(header).to be(:chunked?) + end + end + + with "" do + let(:header) {subject.new} + + it "handles empty transfer encoding" do + expect(header).to be(:empty?) + expect(header).not.to be(:chunked?) + end + end + + with "#<<" do + let(:header) {subject.new} + + it "can add encodings" do + header << "gzip" + expect(header).to be(:gzip?) + + header << "chunked" + expect(header).to be(:chunked?) + end + end + + with ".trailer_forbidden?" do + it "should be forbidden in trailers" do + expect(subject.trailer_forbidden?).to be == true + end + end +end \ No newline at end of file diff --git a/test/protocol/http/headers.rb b/test/protocol/http/headers.rb index 9db3fd71..7c209728 100644 --- a/test/protocol/http/headers.rb +++ b/test/protocol/http/headers.rb @@ -297,6 +297,68 @@ expect(trailer.to_h).to be == {"etag" => "abcd"} end + + with "forbidden trailers" do + forbidden_trailers = %w[ + authorization + proxy-authorization + www-authenticate + proxy-authenticate + + connection + content-length + transfer-encoding + te + upgrade + trailer + + host + expect + range + + content-type + content-encoding + content-range + + cookie + set-cookie + ] + + forbidden_trailers.each do |key| + it "can't add a #{key.inspect} header in the trailer", unique: key do + trailer = headers.trailer! + headers.to_h # Force indexing + + expect do + headers.add(key, "example") + end.to raise_exception(Protocol::HTTP::ForbiddenTrailerError) + end + end + end + + with "permitted trailers" do + permitted_trailers = [ + "date", + "accept", + "x-foo-bar", + "etag", + "content-md5", + "expires", + ] + + permitted_trailers.each do |key| + it "can add a #{key.inspect} header in the trailer", unique: key do + trailer = headers.trailer! + headers.to_h # Force indexing + + expect do + headers.add(key, "example") + end.not.to raise_exception + + expect(headers).to be(:include?, key) + end + end + end end with "#trailer" do From c4adb7b767b5b16e882948014fdd46a9ef03cde0 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 19 Sep 2025 17:50:17 +1200 Subject: [PATCH 02/14] Deny trailers by default. --- examples/trailer_headers_example.rb | 118 +++++++++ lib/protocol/http/error.rb | 12 - lib/protocol/http/header/accept.rb | 2 +- lib/protocol/http/header/authorization.rb | 4 +- lib/protocol/http/header/connection.rb | 4 +- lib/protocol/http/header/cookie.rb | 4 +- lib/protocol/http/header/date.rb | 4 +- lib/protocol/http/header/digest.rb | 70 ++++++ lib/protocol/http/header/etag.rb | 4 +- lib/protocol/http/header/multiple.rb | 2 +- lib/protocol/http/header/server_timing.rb | 95 +++++++ lib/protocol/http/header/split.rb | 2 +- lib/protocol/http/header/te.rb | 4 +- lib/protocol/http/header/trailer.rb | 4 +- lib/protocol/http/header/transfer_encoding.rb | 4 +- lib/protocol/http/headers.rb | 58 +++-- test/protocol/http/header/digest.rb | 175 +++++++++++++ test/protocol/http/header/multiple.rb | 6 +- test/protocol/http/header/server_timing.rb | 232 ++++++++++++++++++ test/protocol/http/header/te.rb | 4 +- test/protocol/http/header/trailer.rb | 4 +- .../protocol/http/header/transfer_encoding.rb | 4 +- test/protocol/http/headers.rb | 111 +++++++-- 23 files changed, 847 insertions(+), 80 deletions(-) create mode 100644 examples/trailer_headers_example.rb create mode 100644 lib/protocol/http/header/digest.rb create mode 100644 lib/protocol/http/header/server_timing.rb create mode 100644 test/protocol/http/header/digest.rb create mode 100644 test/protocol/http/header/server_timing.rb diff --git a/examples/trailer_headers_example.rb b/examples/trailer_headers_example.rb new file mode 100644 index 00000000..fa8f5cb3 --- /dev/null +++ b/examples/trailer_headers_example.rb @@ -0,0 +1,118 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require_relative "../lib/protocol/http/headers" +require "digest" + +# Example: Using various headers suitable for trailers +puts "HTTP Trailers - Suitable Headers Example" +puts "=" * 50 + +# Create a new headers collection +headers = Protocol::HTTP::Headers.new + +# Add regular response headers +headers.add("content-type", "application/json") +headers.add("content-length", "2048") + +# Enable trailers for headers that are calculated during response generation +headers.trailer! + +puts "Regular Headers:" +headers.each do |key, value| + next if headers.trailer? && headers.trailer.any? {|tk, _| tk == key} + puts " #{key}: #{value}" +end + +puts "\nSimulating response generation and trailer calculation..." + +# 1. Server-Timing - Performance metrics calculated during processing +puts "\n1. Server-Timing Header:" +server_timing = Protocol::HTTP::Header::ServerTiming.new +server_timing << "db;dur=45.2;desc=\"Database query\"" +server_timing << "cache;dur=12.8;desc=\"Redis lookup\"" +server_timing << "render;dur=23.5;desc=\"JSON serialization\"" + +headers.add("server-timing", server_timing.to_s) +puts " Added: #{server_timing}" + +# 2. Digest - Content integrity calculated after body generation +puts "\n2. Digest Header:" +response_body = '{"message": "Hello, World!", "timestamp": "2025-09-19T06:18:21Z"}' +sha256_digest = Digest::SHA256.base64digest(response_body) +md5_digest = Digest::MD5.hexdigest(response_body) + +digest = Protocol::HTTP::Header::Digest.new +digest << "sha-256=#{sha256_digest}" +digest << "md5=#{md5_digest}" + +headers.add("digest", digest.to_s) +puts " Added: #{digest}" +puts " Response body: #{response_body}" + +# 3. Custom Application Header - Application-specific metadata +puts "\n3. Custom Application Header:" +headers.add("x-processing-stats", "requests=1250, cache_hits=892, errors=0") +puts " Added: x-processing-stats=requests=1250, cache_hits=892, errors=0" + +# 4. Date - Response completion timestamp +puts "\n4. Date Header:" +completion_time = Time.now +headers.add("date", completion_time.httpdate) +puts " Added: #{completion_time.httpdate}" + +# 5. ETag - Content-based entity tag (when calculated from response) +puts "\n5. ETag Header:" +etag_value = "\"#{Digest::SHA1.hexdigest(response_body)[0..15]}\"" +headers.add("etag", etag_value) +puts " Added: #{etag_value}" + +puts "\nFinal Trailer Headers (sent after response body):" +puts "-" * 50 +headers.trailer do |key, value| + puts " #{key}: #{value}" +end + +puts "\nWhy These Headers Are Perfect for Trailers:" +puts "-" * 45 +puts "• Server-Timing: Performance metrics collected during processing" +puts "• Digest: Content hashes calculated after body generation" +puts "• Custom Headers: Application-specific metadata generated during response" +puts "• Date: Completion timestamp when response finishes" +puts "• ETag: Content-based tags when derived from response body" + +puts "\nBenefits of Using Trailers:" +puts "• No need to buffer entire response to calculate metadata" +puts "• Streaming-friendly - can start sending body immediately" +puts "• Perfect for large responses where metadata depends on content" +puts "• Maintains HTTP semantics while enabling efficient processing" + +# Demonstrate header integration and parsing +puts "\nHeader Integration Examples:" +puts "-" * 30 + +# Show that these headers work normally in the main header section too +normal_headers = Protocol::HTTP::Headers.new +normal_headers.add("server-timing", "total;dur=150.5") +normal_headers.add("digest", "sha-256=abc123") +normal_headers.add("x-cache-status", "hit") + +puts "Normal headers (not trailers):" +normal_headers.each do |key, value| + puts " #{key}: #{value}" +end + +puts "\nParsing capabilities:" +parsed_digest = Protocol::HTTP::Header::Digest.new("sha-256=#{sha256_digest}, md5=#{md5_digest}") +entries = parsed_digest.entries +puts "• Parsed digest entries: #{entries.size}" +puts "• First algorithm: #{entries.first.algorithm}" +puts "• Algorithms: #{entries.map(&:algorithm).join(', ')}" + +parsed_timing = Protocol::HTTP::Header::ServerTiming.new("db;dur=25.4, cache;dur=8.2;desc=\"Redis hit\"") +timing_metrics = parsed_timing.metrics +puts "• Parsed timing metrics: #{timing_metrics.size}" +puts "• First metric: #{timing_metrics.first.name} (#{timing_metrics.first.duration}ms)" diff --git a/lib/protocol/http/error.rb b/lib/protocol/http/error.rb index c4044647..0e9e05e7 100644 --- a/lib/protocol/http/error.rb +++ b/lib/protocol/http/error.rb @@ -26,17 +26,5 @@ def initialize(key) # @attribute [String] key The header key that was duplicated. attr :key end - - class ForbiddenTrailerError < Error - include BadRequest - - # @parameter key [String] The header key that was forbidden in trailers. - def initialize(key) - super("#{key} is forbidden in trailers!") - end - - # @attribute [String] key The header key that was forbidden in trailers. - attr :key - end end end diff --git a/lib/protocol/http/header/accept.rb b/lib/protocol/http/header/accept.rb index f1847ce8..44058bdb 100644 --- a/lib/protocol/http/header/accept.rb +++ b/lib/protocol/http/header/accept.rb @@ -92,7 +92,7 @@ def to_s join(",") end - def self.trailer_forbidden? + def self.trailer? false end diff --git a/lib/protocol/http/header/authorization.rb b/lib/protocol/http/header/authorization.rb index e8380b02..75528415 100644 --- a/lib/protocol/http/header/authorization.rb +++ b/lib/protocol/http/header/authorization.rb @@ -35,8 +35,8 @@ def self.basic(username, password) ) end - def self.trailer_forbidden? - true + def self.trailer? + false end end end diff --git a/lib/protocol/http/header/connection.rb b/lib/protocol/http/header/connection.rb index 02036b5d..0dafe197 100644 --- a/lib/protocol/http/header/connection.rb +++ b/lib/protocol/http/header/connection.rb @@ -51,8 +51,8 @@ def upgrade? self.include?(UPGRADE) end - def self.trailer_forbidden? - true + def self.trailer? + false end end end diff --git a/lib/protocol/http/header/cookie.rb b/lib/protocol/http/header/cookie.rb index f9052c5e..127d88f6 100644 --- a/lib/protocol/http/header/cookie.rb +++ b/lib/protocol/http/header/cookie.rb @@ -24,8 +24,8 @@ def to_h cookies.map{|cookie| [cookie.name, cookie]}.to_h end - def self.trailer_forbidden? - true + def self.trailer? + false end end diff --git a/lib/protocol/http/header/date.rb b/lib/protocol/http/header/date.rb index 1b7b7774..fb94cedf 100644 --- a/lib/protocol/http/header/date.rb +++ b/lib/protocol/http/header/date.rb @@ -26,8 +26,8 @@ def to_time ::Time.parse(self) end - def self.trailer_forbidden? - false + def self.trailer? + true end end end diff --git a/lib/protocol/http/header/digest.rb b/lib/protocol/http/header/digest.rb new file mode 100644 index 00000000..208a18bf --- /dev/null +++ b/lib/protocol/http/header/digest.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require_relative "split" +require_relative "quoted_string" +require_relative "../error" + +module Protocol + module HTTP + module Header + # The `digest` header provides a digest of the message body for integrity verification. + # + # This header allows servers to send cryptographic hashes of the response body, enabling clients to verify data integrity. Multiple digest algorithms can be specified, and the header is particularly useful as a trailer since the digest can only be computed after the entire message body is available. + # + # ## Examples + # + # ```ruby + # digest = Digest.new("sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=") + # digest << "md5=9bb58f26192e4ba00f01e2e7b136bbd8" + # puts digest.to_s + # # => "sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=, md5=9bb58f26192e4ba00f01e2e7b136bbd8" + # ``` + class Digest < Split + ParseError = Class.new(Error) + + # https://tools.ietf.org/html/rfc3230#section-4.3.2 + ENTRY = /\A(?[a-zA-Z0-9][a-zA-Z0-9\-]*)\s*=\s*(?.*)\z/ + + # A single digest entry in the Digest header. + Entry = Struct.new(:algorithm, :value) do + # Create a new digest entry. + # + # @parameter algorithm [String] the digest algorithm (e.g., "sha-256", "md5"). + # @parameter value [String] the base64-encoded or hex-encoded digest value. + def initialize(algorithm, value) + super(algorithm.downcase, value) + end + + # Convert the entry to its string representation. + # + # @returns [String] the formatted digest string. + def to_s + "#{algorithm}=#{value}" + end + end + + # Parse the `digest` header value into a list of digest entries. + # + # @returns [Array(Entry)] the list of digest entries with their algorithms and values. + def entries + self.map do |value| + if match = value.match(ENTRY) + Entry.new(match[:algorithm], match[:value]) + else + raise ParseError.new("Could not parse digest value: #{value.inspect}") + end + end + end + + # Digest headers are perfect for use as trailers since they contain + # integrity hashes that can only be calculated after the entire message body is available. + def self.trailer? + true + end + end + end + end +end \ No newline at end of file diff --git a/lib/protocol/http/header/etag.rb b/lib/protocol/http/header/etag.rb index 1bfbc3a9..8f39ef9a 100644 --- a/lib/protocol/http/header/etag.rb +++ b/lib/protocol/http/header/etag.rb @@ -26,8 +26,8 @@ def weak? self.start_with?("W/") end - def self.trailer_forbidden? - false + def self.trailer? + true end end end diff --git a/lib/protocol/http/header/multiple.rb b/lib/protocol/http/header/multiple.rb index 49b0961a..170422ab 100644 --- a/lib/protocol/http/header/multiple.rb +++ b/lib/protocol/http/header/multiple.rb @@ -26,7 +26,7 @@ def to_s join("\n") end - def self.trailer_forbidden? + def self.trailer? false end end diff --git a/lib/protocol/http/header/server_timing.rb b/lib/protocol/http/header/server_timing.rb new file mode 100644 index 00000000..dea3bbeb --- /dev/null +++ b/lib/protocol/http/header/server_timing.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require_relative "split" +require_relative "quoted_string" +require_relative "../error" + +module Protocol + module HTTP + module Header + # The `server-timing` header communicates performance metrics about the request-response cycle to the client. + # + # This header allows servers to send timing information about various server-side operations, which can be useful for performance monitoring and debugging. Each metric can include a name, optional duration, and optional description. + # + # ## Examples + # + # ```ruby + # server_timing = ServerTiming.new("db;dur=53.2") + # server_timing << "cache;dur=12.1;desc=\"Redis lookup\"" + # puts server_timing.to_s + # # => "db;dur=53.2, cache;dur=12.1;desc=\"Redis lookup\"" + # ``` + class ServerTiming < Split + ParseError = Class.new(Error) + + # https://www.w3.org/TR/server-timing/ + METRIC = /\A(?[a-zA-Z0-9][a-zA-Z0-9_\-]*)(;(?.*))?\z/ + PARAM = /(?dur|desc)=(?[^;,]+|"[^"]*")/ + + # A single metric in the Server-Timing header. + Metric = Struct.new(:name, :duration, :description) do + # Create a new server timing metric. + # + # @parameter name [String] the name of the metric. + # @parameter duration [Float | Nil] the duration in milliseconds. + # @parameter description [String | Nil] the description of the metric. + def initialize(name, duration = nil, description = nil) + super(name, duration, description) + end + + # Convert the metric to its string representation. + # + # @returns [String] the formatted metric string. + def to_s + result = name.dup + result << ";dur=#{duration}" if duration + result << ";desc=\"#{description}\"" if description + result + end + end + + # Parse the `server-timing` header value into a list of metrics. + # + # @returns [Array(Metric)] the list of metrics with their names, durations, and descriptions. + def metrics + self.map do |value| + if match = value.match(METRIC) + name = match[:name] + params = match[:params] || "" + + duration = nil + description = nil + + params.scan(PARAM) do |key, param_value| + case key + when "dur" + duration = param_value.to_f + when "desc" + # Remove quotes if present + if param_value.start_with?('"') && param_value.end_with?('"') + description = param_value[1..-2] + else + description = param_value + end + end + end + + Metric.new(name, duration, description) + else + raise ParseError.new("Could not parse server timing metric: #{value.inspect}") + end + end + end + + # Server-Timing headers are safe to use as trailers since they contain + # performance metrics that are typically calculated during response generation. + def self.trailer? + true + end + end + end + end +end diff --git a/lib/protocol/http/header/split.rb b/lib/protocol/http/header/split.rb index 6292dca7..386d0026 100644 --- a/lib/protocol/http/header/split.rb +++ b/lib/protocol/http/header/split.rb @@ -40,7 +40,7 @@ def to_s join(",") end - def self.trailer_forbidden? + def self.trailer? false end diff --git a/lib/protocol/http/header/te.rb b/lib/protocol/http/header/te.rb index 83688b85..82071d85 100644 --- a/lib/protocol/http/header/te.rb +++ b/lib/protocol/http/header/te.rb @@ -119,8 +119,8 @@ def trailers? self.any? {|value| value.start_with?(TRAILERS)} end - def self.trailer_forbidden? - true + def self.trailer? + false end end end diff --git a/lib/protocol/http/header/trailer.rb b/lib/protocol/http/header/trailer.rb index ac9a4c84..b26a7d73 100644 --- a/lib/protocol/http/header/trailer.rb +++ b/lib/protocol/http/header/trailer.rb @@ -12,8 +12,8 @@ module Header # # This isn't a specific header class is a utility for handling headers with comma-separated values, such as `accept`, `cache-control`, and other similar headers. The values are split and stored as an array internally, and serialized back to a comma-separated string when needed. class Trailer < Split - def self.trailer_forbidden? - true + def self.trailer? + false end end end diff --git a/lib/protocol/http/header/transfer_encoding.rb b/lib/protocol/http/header/transfer_encoding.rb index 98d931e4..7a9ae034 100644 --- a/lib/protocol/http/header/transfer_encoding.rb +++ b/lib/protocol/http/header/transfer_encoding.rb @@ -66,8 +66,8 @@ def identity? self.include?(IDENTITY) end - def self.trailer_forbidden? - true + def self.trailer? + false end end end diff --git a/lib/protocol/http/headers.rb b/lib/protocol/http/headers.rb index b48285ab..b3498f9c 100644 --- a/lib/protocol/http/headers.rb +++ b/lib/protocol/http/headers.rb @@ -18,6 +18,8 @@ require_relative "header/date" require_relative "header/priority" require_relative "header/trailer" +require_relative "header/server_timing" +require_relative "header/digest" require_relative "header/accept" require_relative "header/accept_charset" @@ -68,7 +70,7 @@ def self.[] headers # # @parameter fields [Array] An array of `[key, value]` pairs. # @parameter tail [Integer | Nil] The index of the trailer start in the @fields array. - def initialize(fields = [], tail = nil, indexed: nil) + def initialize(fields = [], tail = nil, indexed: nil, policy: POLICY) @fields = fields # Marks where trailer start in the @fields array: @@ -76,6 +78,21 @@ def initialize(fields = [], tail = nil, indexed: nil) # The cached index of headers: @indexed = nil + + @policy = policy + end + + # @attribute [Hash] The policy for the headers. + attr :policy + + # Set the policy for the headers. + # + # The policy is used to determine how headers are merged and normalized. For example, if a header is specified multiple times, the policy will determine how the values are merged. + # + # @parameter policy [Hash] The policy for the headers. + def policy=(policy) + @policy = policy + @indexed = nil end # Initialize a copy of the headers. @@ -247,26 +264,15 @@ def merge(headers) self.dup.merge!(headers) end - # Singleton header policy that forbids trailers (for message framing headers) - class TrailerForbidden - def self.new(value) - value - end - - def self.trailer_forbidden? - true - end - end - # The policy for various headers, including how they are merged and normalized. POLICY = { # Headers which may only be specified once: "content-disposition" => false, - "content-length" => TrailerForbidden, + "content-length" => false, "content-type" => false, - "expect" => TrailerForbidden, + "expect" => false, "from" => false, - "host" => TrailerForbidden, + "host" => false, "location" => false, "max-forwards" => false, "range" => false, @@ -274,7 +280,6 @@ def self.trailer_forbidden? "retry-after" => false, "server" => false, "transfer-encoding" => Header::TransferEncoding, - "upgrade" => TrailerForbidden, "user-agent" => false, "trailer" => Header::Trailer, @@ -320,6 +325,12 @@ def self.trailer_forbidden? "accept-charset" => Header::AcceptCharset, "accept-encoding" => Header::AcceptEncoding, "accept-language" => Header::AcceptLanguage, + + # Performance headers: + "server-timing" => Header::ServerTiming, + + # Content integrity headers: + "digest" => Header::Digest, }.tap{|hash| hash.default = Split} # Delete all header values for the given key, and return the merged value. @@ -337,7 +348,7 @@ def delete(key) if @indexed return @indexed.delete(key) - elsif policy = POLICY[key] + elsif policy = @policy[key] (key, value), *tail = deleted merged = policy.new(value) @@ -356,10 +367,10 @@ def delete(key) # @parameter key [String] The header key. # @parameter value [String] The raw header value. protected def merge_into(hash, key, value, trailer = @tail) - if policy = POLICY[key] - # Check if we're adding to trailers and this header is forbidden - if trailer && policy.trailer_forbidden? - raise ForbiddenTrailerError, key + if policy = @policy[key] + # Check if we're adding to trailers and this header is allowed: + if trailer && !policy.trailer? + return false end if current_value = hash[key] @@ -368,6 +379,11 @@ def delete(key) hash[key] = policy.new(value) end else + # By default, headers are not allowed in trailers: + if trailer + return false + end + if hash.key?(key) raise DuplicateHeaderError, key end diff --git a/test/protocol/http/header/digest.rb b/test/protocol/http/header/digest.rb new file mode 100644 index 00000000..f3a8cc6d --- /dev/null +++ b/test/protocol/http/header/digest.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "protocol/http/header/digest" +require "sus" + +describe Protocol::HTTP::Header::Digest do + let(:header) {subject.new(description)} + + with "empty header" do + let(:header) {subject.new} + + it "should be empty" do + expect(header.to_s).to be == "" + end + + it "should be an array" do + expect(header).to be_a(Array) + end + + it "should return empty entries" do + expect(header.entries).to be == [] + end + end + + with "sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" do + it "can parse a single entry" do + entries = header.entries + expect(entries.size).to be == 1 + expect(entries.first.algorithm).to be == "sha-256" + expect(entries.first.value).to be == "X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" + end + end + + with "sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=, md5=9bb58f26192e4ba00f01e2e7b136bbd8" do + it "can parse multiple entries" do + entries = header.entries + expect(entries.size).to be == 2 + expect(entries[0].algorithm).to be == "sha-256" + expect(entries[1].algorithm).to be == "md5" + end + end + + with "SHA-256=abc123" do + it "normalizes algorithm to lowercase" do + entries = header.entries + expect(entries.first.algorithm).to be == "sha-256" + end + end + + with "sha-256 = X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" do + it "handles whitespace around equals sign" do + entries = header.entries + expect(entries.first.algorithm).to be == "sha-256" + expect(entries.first.value).to be == "X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" + end + end + + with "invalid-format-no-equals" do + it "raises ParseError for invalid format" do + expect do + header.entries + end.to raise_exception(Protocol::HTTP::Header::Digest::ParseError) + end + end + + with "#<<" do + let(:header) {subject.new} + + it "can add entries from string" do + header << "sha-256=abc123" + header << "md5=def456" + expect(header.size).to be == 2 + + entries = header.entries + expect(entries[0].algorithm).to be == "sha-256" + expect(entries[1].algorithm).to be == "md5" + end + + it "can add multiple entries at once" do + header << "sha-256=abc123, md5=def456" + expect(header.size).to be == 2 + + entries = header.entries + expect(entries[0].algorithm).to be == "sha-256" + expect(entries[1].algorithm).to be == "md5" + end + end + + with "inherited Split behavior" do + let(:header) {subject.new} + + it "behaves as an array" do + header << "sha-256=abc123" + expect(header.size).to be == 1 + expect(header.first).to be == "sha-256=abc123" + end + + it "can be enumerated" do + header << "sha-256=abc123, md5=def456" + values = [] + header.each {|value| values << value} + expect(values).to be == ["sha-256=abc123", "md5=def456"] + end + + it "supports array methods" do + header << "sha-256=abc123, md5=def456" + expect(header.length).to be == 2 + expect(header.empty?).to be == false + end + end + + with "trailer support" do + it "should be allowed as a trailer" do + expect(subject.trailer?).to be == true + end + end + + with "Entry class" do + let(:entry_class) {subject::Entry} + + it "can create entry directly" do + entry = entry_class.new("sha-256", "abc123") + expect(entry.algorithm).to be == "sha-256" + expect(entry.value).to be == "abc123" + expect(entry.to_s).to be == "sha-256=abc123" + end + + it "normalizes algorithm to lowercase" do + entry = entry_class.new("SHA-256", "abc123") + expect(entry.algorithm).to be == "sha-256" + end + + it "handles complex algorithm names" do + entry = entry_class.new("sha-384", "complex-value") + expect(entry.algorithm).to be == "sha-384" + expect(entry.to_s).to be == "sha-384=complex-value" + end + + it "handles base64 padding in values" do + entry = entry_class.new("md5", "abc123==") + expect(entry.value).to be == "abc123==" + end + end + + with "algorithm edge cases" do + it "handles hyphenated algorithms" do + header = subject.new("sha-256=abc123") + entries = header.entries + expect(entries.first.algorithm).to be == "sha-256" + end + + it "handles numeric algorithms" do + header = subject.new("md5=def456") + entries = header.entries + expect(entries.first.algorithm).to be == "md5" + end + end + + with "value edge cases" do + it "handles empty values" do + header = subject.new("sha-256=") + entries = header.entries + expect(entries.first.value).to be == "" + end + + it "handles values with special characters" do + header = subject.new("sha-256=abc+def/123==") + entries = header.entries + expect(entries.first.value).to be == "abc+def/123==" + end + end +end \ No newline at end of file diff --git a/test/protocol/http/header/multiple.rb b/test/protocol/http/header/multiple.rb index 427c45bf..48479f06 100644 --- a/test/protocol/http/header/multiple.rb +++ b/test/protocol/http/header/multiple.rb @@ -20,9 +20,9 @@ end end - with ".trailer_forbidden?" do - it "is allowed in trailers by default" do - expect(subject).not.to be(:trailer_forbidden?) + with ".trailer?" do + it "is not allowed in trailers by default" do + expect(subject).not.to be(:trailer?) end end end diff --git a/test/protocol/http/header/server_timing.rb b/test/protocol/http/header/server_timing.rb new file mode 100644 index 00000000..22595e1d --- /dev/null +++ b/test/protocol/http/header/server_timing.rb @@ -0,0 +1,232 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "protocol/http/header/server_timing" +require "sus" + +describe Protocol::HTTP::Header::ServerTiming do + let(:header) {subject.new(description)} + + with "empty header" do + let(:header) {subject.new} + + it "should be empty" do + expect(header.to_s).to be == "" + end + + it "should be an array" do + expect(header).to be_a(Array) + end + + it "should return empty metrics" do + expect(header.metrics).to be == [] + end + end + + with "db;dur=53.2" do + it "can parse metric with duration" do + metrics = header.metrics + expect(metrics.size).to be == 1 + expect(metrics.first.name).to be == "db" + expect(metrics.first.duration).to be == 53.2 + expect(metrics.first.description).to be_nil + end + end + + with 'cache;desc="Redis lookup"' do + it "can parse metric with description" do + metrics = header.metrics + expect(metrics.size).to be == 1 + expect(metrics.first.name).to be == "cache" + expect(metrics.first.duration).to be_nil + expect(metrics.first.description).to be == "Redis lookup" + end + end + + with 'app;dur=12.7;desc="Application logic"' do + it "can parse metric with duration and description" do + metrics = header.metrics + expect(metrics.first.name).to be == "app" + expect(metrics.first.duration).to be == 12.7 + expect(metrics.first.description).to be == "Application logic" + end + end + + with "db;dur=45.3, app;dur=12.7;desc=\"Application logic\", cache;desc=\"Cache miss\"" do + it "can parse multiple metrics" do + metrics = header.metrics + expect(metrics.size).to be == 3 + + expect(metrics[0].name).to be == "db" + expect(metrics[0].duration).to be == 45.3 + expect(metrics[0].description).to be_nil + + expect(metrics[1].name).to be == "app" + expect(metrics[1].duration).to be == 12.7 + expect(metrics[1].description).to be == "Application logic" + + expect(metrics[2].name).to be == "cache" + expect(metrics[2].duration).to be_nil + expect(metrics[2].description).to be == "Cache miss" + end + end + + with "cache-hit" do + it "can parse metric with name only" do + metrics = header.metrics + expect(metrics.first.name).to be == "cache-hit" + expect(metrics.first.duration).to be_nil + expect(metrics.first.description).to be_nil + end + end + + with "invalid;unknown=param" do + it "ignores unknown parameters" do + metrics = header.metrics + expect(metrics.first.name).to be == "invalid" + expect(metrics.first.duration).to be_nil + expect(metrics.first.description).to be_nil + end + end + + with "invalid-metric-name!" do + it "raises ParseError for invalid metric name" do + expect do + header.metrics + end.to raise_exception(Protocol::HTTP::Header::ServerTiming::ParseError) + end + end + + with "#<<" do + let(:header) {subject.new} + + it "can add metrics from string" do + header << "db;dur=25.5" + header << "cache;dur=5.2;desc=\"Hit\"" + expect(header.size).to be == 2 + + metrics = header.metrics + expect(metrics[0].name).to be == "db" + expect(metrics[1].name).to be == "cache" + end + + it "can add multiple metrics at once" do + header << "db;dur=25.5, cache;desc=\"Hit\"" + expect(header.size).to be == 2 + + metrics = header.metrics + expect(metrics[0].name).to be == "db" + expect(metrics[1].name).to be == "cache" + end + end + + with "inherited Split behavior" do + let(:header) {subject.new} + + it "behaves as an array" do + header << "db;dur=25.5" + expect(header.size).to be == 1 + expect(header.first).to be == "db;dur=25.5" + end + + it "can be enumerated" do + header << "db;dur=25.5, cache;desc=\"Hit\"" + values = [] + header.each {|value| values << value} + expect(values).to be == ["db;dur=25.5", "cache;desc=\"Hit\""] + end + + it "supports array methods" do + header << "db;dur=25.5, cache;desc=\"Hit\"" + expect(header.length).to be == 2 + expect(header.empty?).to be == false + end + end + + with "trailer support" do + it "should be allowed as a trailer" do + expect(subject.trailer?).to be == true + end + end + + with "cache_hit" do + it "can parse metric with underscore in name" do + metrics = header.metrics + expect(metrics.first.name).to be == "cache_hit" + end + end + + with "test;desc=unquoted-value" do + it "can parse unquoted description" do + metrics = header.metrics + expect(metrics.first.description).to be == "unquoted-value" + end + end + + with 'test;desc=""' do + it "can parse empty quoted description" do + metrics = header.metrics + expect(metrics.first.description).to be == "" + end + end + + with "test;dur=123;desc=mixed;unknown=ignored" do + it "ignores unknown parameters and processes known ones" do + metrics = header.metrics + expect(metrics.first.name).to be == "test" + expect(metrics.first.duration).to be == 123.0 + expect(metrics.first.description).to be == "mixed" + end + end + + with "test;dur=0" do + it "can parse zero duration" do + metrics = header.metrics + expect(metrics.first.duration).to be == 0.0 + end + end + + with "test;dur=123.456789" do + it "preserves decimal precision" do + metrics = header.metrics + expect(metrics.first.duration).to be == 123.456789 + end + end + + with "Metric class" do + let(:metric_class) {subject::Metric} + + it "can create metric directly" do + metric = metric_class.new("test", 123.45, "Test metric") + expect(metric.name).to be == "test" + expect(metric.duration).to be == 123.45 + expect(metric.description).to be == "Test metric" + expect(metric.to_s).to be == "test;dur=123.45;desc=\"Test metric\"" + end + + it "can create metric with name only" do + metric = metric_class.new("cache") + expect(metric.name).to be == "cache" + expect(metric.duration).to be_nil + expect(metric.description).to be_nil + expect(metric.to_s).to be == "cache" + end + + it "can create metric with duration only" do + metric = metric_class.new("test", 123.45, nil) + expect(metric.to_s).to be == "test;dur=123.45" + end + + it "can create metric with description only" do + metric = metric_class.new("test", nil, "description") + expect(metric.to_s).to be == "test;desc=\"description\"" + end + + it "handles nil values correctly" do + metric = metric_class.new("test", nil, nil) + expect(metric.to_s).to be == "test" + end + end +end \ No newline at end of file diff --git a/test/protocol/http/header/te.rb b/test/protocol/http/header/te.rb index f92df7d1..6473904e 100644 --- a/test/protocol/http/header/te.rb +++ b/test/protocol/http/header/te.rb @@ -126,9 +126,9 @@ end end - with ".trailer_forbidden?" do + with ".trailer?" do it "should be forbidden in trailers" do - expect(subject.trailer_forbidden?).to be == true + expect(subject).not.to be(:trailer?) end end end \ No newline at end of file diff --git a/test/protocol/http/header/trailer.rb b/test/protocol/http/header/trailer.rb index 090e54e3..ae817aa3 100644 --- a/test/protocol/http/header/trailer.rb +++ b/test/protocol/http/header/trailer.rb @@ -68,9 +68,9 @@ end end - with ".trailer_forbidden?" do + with ".trailer?" do it "should be forbidden in trailers" do - expect(subject.trailer_forbidden?).to be == true + expect(subject).not.to be(:trailer?) end end end \ No newline at end of file diff --git a/test/protocol/http/header/transfer_encoding.rb b/test/protocol/http/header/transfer_encoding.rb index c5517e4b..0e6066b2 100644 --- a/test/protocol/http/header/transfer_encoding.rb +++ b/test/protocol/http/header/transfer_encoding.rb @@ -69,9 +69,9 @@ end end - with ".trailer_forbidden?" do + with ".trailer?" do it "should be forbidden in trailers" do - expect(subject.trailer_forbidden?).to be == true + expect(subject).not.to be(:trailer?) end end end \ No newline at end of file diff --git a/test/protocol/http/headers.rb b/test/protocol/http/headers.rb index 7c209728..bfaca514 100644 --- a/test/protocol/http/headers.rb +++ b/test/protocol/http/headers.rb @@ -299,62 +299,63 @@ end with "forbidden trailers" do + let(:headers) {subject.new} + forbidden_trailers = %w[ + accept + accept-charset + accept-encoding + accept-language + authorization proxy-authorization www-authenticate proxy-authenticate - + connection content-length transfer-encoding te upgrade trailer - + host expect range - + content-type content-encoding content-range - + cookie set-cookie + + x-foo-bar ] forbidden_trailers.each do |key| it "can't add a #{key.inspect} header in the trailer", unique: key do trailer = headers.trailer! - headers.to_h # Force indexing - - expect do - headers.add(key, "example") - end.to raise_exception(Protocol::HTTP::ForbiddenTrailerError) + headers.add(key, "example") + expect(headers).not.to be(:include?, key) end end end with "permitted trailers" do + let(:headers) {subject.new} + permitted_trailers = [ "date", - "accept", - "x-foo-bar", + "digest", "etag", - "content-md5", - "expires", + "server-timing", ] permitted_trailers.each do |key| it "can add a #{key.inspect} header in the trailer", unique: key do trailer = headers.trailer! - headers.to_h # Force indexing - - expect do - headers.add(key, "example") - end.not.to raise_exception - + headers.add(key, "example") expect(headers).to be(:include?, key) end end @@ -371,6 +372,78 @@ end end + with "custom policy" do + let(:headers) {subject.new} + + # Create a custom header class that allows trailers + let(:grpc_status_class) do + Class.new(String) do + def self.trailer? + true + end + end + end + + it "can set custom policy to allow additional trailer headers" do + # Create custom policy that allows grpc-status as trailer + custom_policy = Protocol::HTTP::Headers::POLICY.dup + custom_policy["grpc-status"] = grpc_status_class + + # Set the custom policy + headers.policy = custom_policy + + # Enable trailers + headers.trailer! + + # Add grpc-status header (should be allowed with custom policy) + headers.add("grpc-status", "0") + + # Verify it appears in trailers + expect(headers).to be(:include?, "grpc-status") + + trailer_headers = {} + headers.trailer do |key, value| + trailer_headers[key] = value + end + + expect(trailer_headers["grpc-status"]).to be == "0" + end + + it "policy= clears indexed cache" do + # Add some headers first + headers.add("content-type", "text/html") + + # Force indexing + hash1 = headers.to_h + expect(hash1).to be(:include?, "content-type") + + # Change policy + new_policy = {} + headers.policy = new_policy + + # Add another header + headers.add("x-custom", "value") + + # Verify cache was cleared and rebuilt + hash2 = headers.to_h + expect(hash2).to be(:include?, "content-type") + expect(hash2).to be(:include?, "x-custom") + end + + it "can read policy attribute" do + original_policy = headers.policy + expect(original_policy).to be == Protocol::HTTP::Headers::POLICY + + # Set new policy + custom_policy = {"custom" => String} + headers.policy = custom_policy + + # Verify policy was changed + expect(headers.policy).to be == custom_policy + expect(headers.policy).not.to be == original_policy + end + end + with "#flatten!" do it "can flatten trailer" do headers.add("trailer", "etag") From ae0834e4540b07ad14890a7e8ef57ea5e2ecf5b2 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 19 Sep 2025 21:33:58 +1200 Subject: [PATCH 03/14] Documentation. --- lib/protocol/http/header/accept.rb | 3 +++ lib/protocol/http/header/authorization.rb | 17 ++++++++++------- lib/protocol/http/header/connection.rb | 3 +++ lib/protocol/http/header/cookie.rb | 3 +++ lib/protocol/http/header/date.rb | 3 +++ lib/protocol/http/header/etag.rb | 3 +++ lib/protocol/http/header/multiple.rb | 3 +++ lib/protocol/http/header/split.rb | 3 +++ lib/protocol/http/header/te.rb | 3 +++ lib/protocol/http/header/trailer.rb | 3 +++ lib/protocol/http/header/transfer_encoding.rb | 3 +++ 11 files changed, 40 insertions(+), 7 deletions(-) diff --git a/lib/protocol/http/header/accept.rb b/lib/protocol/http/header/accept.rb index 44058bdb..5f9a3b64 100644 --- a/lib/protocol/http/header/accept.rb +++ b/lib/protocol/http/header/accept.rb @@ -92,6 +92,9 @@ def to_s join(",") end + # Whether this header is acceptable in HTTP trailers. + # Accept headers in trailers can provide content negotiation hints for subsequent responses. + # @returns [Boolean] false, as Accept headers are generally not needed in trailers. def self.trailer? false end diff --git a/lib/protocol/http/header/authorization.rb b/lib/protocol/http/header/authorization.rb index 75528415..622f7e62 100644 --- a/lib/protocol/http/header/authorization.rb +++ b/lib/protocol/http/header/authorization.rb @@ -31,13 +31,16 @@ def self.basic(username, password) strict_base64_encoded = ["#{username}:#{password}"].pack("m0") self.new( - "Basic #{strict_base64_encoded}" - ) - end - - def self.trailer? - false - end + "Basic #{strict_base64_encoded}" + ) + end + + # Whether this header is acceptable in HTTP trailers. + # Authorization credentials must not appear in trailers for security reasons. + # @returns [Boolean] false, as authorization headers contain sensitive credentials. + def self.trailer? + false + end end end end diff --git a/lib/protocol/http/header/connection.rb b/lib/protocol/http/header/connection.rb index 0dafe197..0485e8fb 100644 --- a/lib/protocol/http/header/connection.rb +++ b/lib/protocol/http/header/connection.rb @@ -51,6 +51,9 @@ def upgrade? self.include?(UPGRADE) end + # Whether this header is acceptable in HTTP trailers. + # Connection headers control the current connection and must not appear in trailers. + # @returns [Boolean] false, as connection headers are hop-by-hop and forbidden in trailers. def self.trailer? false end diff --git a/lib/protocol/http/header/cookie.rb b/lib/protocol/http/header/cookie.rb index 127d88f6..76b251a9 100644 --- a/lib/protocol/http/header/cookie.rb +++ b/lib/protocol/http/header/cookie.rb @@ -24,6 +24,9 @@ def to_h cookies.map{|cookie| [cookie.name, cookie]}.to_h end + # Whether this header is acceptable in HTTP trailers. + # Cookie headers should not appear in trailers as they contain state information needed early in processing. + # @returns [Boolean] false, as cookie headers are needed during initial request processing. def self.trailer? false end diff --git a/lib/protocol/http/header/date.rb b/lib/protocol/http/header/date.rb index fb94cedf..abeea169 100644 --- a/lib/protocol/http/header/date.rb +++ b/lib/protocol/http/header/date.rb @@ -26,6 +26,9 @@ def to_time ::Time.parse(self) end + # Whether this header is acceptable in HTTP trailers. + # Date headers can safely appear in trailers as they provide metadata about response generation. + # @returns [Boolean] true, as date headers are metadata that can be computed after response generation. def self.trailer? true end diff --git a/lib/protocol/http/header/etag.rb b/lib/protocol/http/header/etag.rb index 8f39ef9a..3b7f2d49 100644 --- a/lib/protocol/http/header/etag.rb +++ b/lib/protocol/http/header/etag.rb @@ -26,6 +26,9 @@ def weak? self.start_with?("W/") end + # Whether this header is acceptable in HTTP trailers. + # ETag headers can safely appear in trailers as they provide cache validation metadata. + # @returns [Boolean] true, as ETag headers are metadata that can be computed after response generation. def self.trailer? true end diff --git a/lib/protocol/http/header/multiple.rb b/lib/protocol/http/header/multiple.rb index 170422ab..19e8abc9 100644 --- a/lib/protocol/http/header/multiple.rb +++ b/lib/protocol/http/header/multiple.rb @@ -26,6 +26,9 @@ def to_s join("\n") end + # Whether this header is acceptable in HTTP trailers. + # This is a base class for headers with multiple values, default is to disallow in trailers. + # @returns [Boolean] false, as most multiple-value headers should not appear in trailers by default. def self.trailer? false end diff --git a/lib/protocol/http/header/split.rb b/lib/protocol/http/header/split.rb index 386d0026..7da1da5f 100644 --- a/lib/protocol/http/header/split.rb +++ b/lib/protocol/http/header/split.rb @@ -40,6 +40,9 @@ def to_s join(",") end + # Whether this header is acceptable in HTTP trailers. + # This is a base class for comma-separated headers, default is to disallow in trailers. + # @returns [Boolean] false, as most comma-separated headers should not appear in trailers by default. def self.trailer? false end diff --git a/lib/protocol/http/header/te.rb b/lib/protocol/http/header/te.rb index 82071d85..3ea25ea3 100644 --- a/lib/protocol/http/header/te.rb +++ b/lib/protocol/http/header/te.rb @@ -119,6 +119,9 @@ def trailers? self.any? {|value| value.start_with?(TRAILERS)} end + # Whether this header is acceptable in HTTP trailers. + # TE headers negotiate transfer encodings and must not appear in trailers. + # @returns [Boolean] false, as TE headers are hop-by-hop and control message framing. def self.trailer? false end diff --git a/lib/protocol/http/header/trailer.rb b/lib/protocol/http/header/trailer.rb index b26a7d73..8a3f0ea9 100644 --- a/lib/protocol/http/header/trailer.rb +++ b/lib/protocol/http/header/trailer.rb @@ -12,6 +12,9 @@ module Header # # This isn't a specific header class is a utility for handling headers with comma-separated values, such as `accept`, `cache-control`, and other similar headers. The values are split and stored as an array internally, and serialized back to a comma-separated string when needed. class Trailer < Split + # Whether this header is acceptable in HTTP trailers. + # Trailer headers themselves must not appear in trailers to avoid recursive references. + # @returns [Boolean] false, as Trailer headers control trailer processing and must appear before the message body. def self.trailer? false end diff --git a/lib/protocol/http/header/transfer_encoding.rb b/lib/protocol/http/header/transfer_encoding.rb index 7a9ae034..e0e49e06 100644 --- a/lib/protocol/http/header/transfer_encoding.rb +++ b/lib/protocol/http/header/transfer_encoding.rb @@ -66,6 +66,9 @@ def identity? self.include?(IDENTITY) end + # Whether this header is acceptable in HTTP trailers. + # Transfer-Encoding headers control message framing and must not appear in trailers. + # @returns [Boolean] false, as Transfer-Encoding headers are hop-by-hop and must precede the message body. def self.trailer? false end From 16e84b0f9b2751317c49e89bcabfeffdc1ef0186 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 19 Sep 2025 21:37:53 +1200 Subject: [PATCH 04/14] RuboCop. --- lib/protocol/http/header/authorization.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/protocol/http/header/authorization.rb b/lib/protocol/http/header/authorization.rb index 622f7e62..9628d599 100644 --- a/lib/protocol/http/header/authorization.rb +++ b/lib/protocol/http/header/authorization.rb @@ -33,14 +33,14 @@ def self.basic(username, password) self.new( "Basic #{strict_base64_encoded}" ) - end - - # Whether this header is acceptable in HTTP trailers. - # Authorization credentials must not appear in trailers for security reasons. - # @returns [Boolean] false, as authorization headers contain sensitive credentials. - def self.trailer? - false - end + end + + # Whether this header is acceptable in HTTP trailers. + # Authorization credentials must not appear in trailers for security reasons. + # @returns [Boolean] false, as authorization headers contain sensitive credentials. + def self.trailer? + false + end end end end From a34bfdd092b551cbe414f5bf8ef2373e5b3fa926 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 19 Sep 2025 22:15:09 +1200 Subject: [PATCH 05/14] Remove example. --- examples/trailer_headers_example.rb | 118 ---------------------------- 1 file changed, 118 deletions(-) delete mode 100644 examples/trailer_headers_example.rb diff --git a/examples/trailer_headers_example.rb b/examples/trailer_headers_example.rb deleted file mode 100644 index fa8f5cb3..00000000 --- a/examples/trailer_headers_example.rb +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2025, by Samuel Williams. - -require_relative "../lib/protocol/http/headers" -require "digest" - -# Example: Using various headers suitable for trailers -puts "HTTP Trailers - Suitable Headers Example" -puts "=" * 50 - -# Create a new headers collection -headers = Protocol::HTTP::Headers.new - -# Add regular response headers -headers.add("content-type", "application/json") -headers.add("content-length", "2048") - -# Enable trailers for headers that are calculated during response generation -headers.trailer! - -puts "Regular Headers:" -headers.each do |key, value| - next if headers.trailer? && headers.trailer.any? {|tk, _| tk == key} - puts " #{key}: #{value}" -end - -puts "\nSimulating response generation and trailer calculation..." - -# 1. Server-Timing - Performance metrics calculated during processing -puts "\n1. Server-Timing Header:" -server_timing = Protocol::HTTP::Header::ServerTiming.new -server_timing << "db;dur=45.2;desc=\"Database query\"" -server_timing << "cache;dur=12.8;desc=\"Redis lookup\"" -server_timing << "render;dur=23.5;desc=\"JSON serialization\"" - -headers.add("server-timing", server_timing.to_s) -puts " Added: #{server_timing}" - -# 2. Digest - Content integrity calculated after body generation -puts "\n2. Digest Header:" -response_body = '{"message": "Hello, World!", "timestamp": "2025-09-19T06:18:21Z"}' -sha256_digest = Digest::SHA256.base64digest(response_body) -md5_digest = Digest::MD5.hexdigest(response_body) - -digest = Protocol::HTTP::Header::Digest.new -digest << "sha-256=#{sha256_digest}" -digest << "md5=#{md5_digest}" - -headers.add("digest", digest.to_s) -puts " Added: #{digest}" -puts " Response body: #{response_body}" - -# 3. Custom Application Header - Application-specific metadata -puts "\n3. Custom Application Header:" -headers.add("x-processing-stats", "requests=1250, cache_hits=892, errors=0") -puts " Added: x-processing-stats=requests=1250, cache_hits=892, errors=0" - -# 4. Date - Response completion timestamp -puts "\n4. Date Header:" -completion_time = Time.now -headers.add("date", completion_time.httpdate) -puts " Added: #{completion_time.httpdate}" - -# 5. ETag - Content-based entity tag (when calculated from response) -puts "\n5. ETag Header:" -etag_value = "\"#{Digest::SHA1.hexdigest(response_body)[0..15]}\"" -headers.add("etag", etag_value) -puts " Added: #{etag_value}" - -puts "\nFinal Trailer Headers (sent after response body):" -puts "-" * 50 -headers.trailer do |key, value| - puts " #{key}: #{value}" -end - -puts "\nWhy These Headers Are Perfect for Trailers:" -puts "-" * 45 -puts "• Server-Timing: Performance metrics collected during processing" -puts "• Digest: Content hashes calculated after body generation" -puts "• Custom Headers: Application-specific metadata generated during response" -puts "• Date: Completion timestamp when response finishes" -puts "• ETag: Content-based tags when derived from response body" - -puts "\nBenefits of Using Trailers:" -puts "• No need to buffer entire response to calculate metadata" -puts "• Streaming-friendly - can start sending body immediately" -puts "• Perfect for large responses where metadata depends on content" -puts "• Maintains HTTP semantics while enabling efficient processing" - -# Demonstrate header integration and parsing -puts "\nHeader Integration Examples:" -puts "-" * 30 - -# Show that these headers work normally in the main header section too -normal_headers = Protocol::HTTP::Headers.new -normal_headers.add("server-timing", "total;dur=150.5") -normal_headers.add("digest", "sha-256=abc123") -normal_headers.add("x-cache-status", "hit") - -puts "Normal headers (not trailers):" -normal_headers.each do |key, value| - puts " #{key}: #{value}" -end - -puts "\nParsing capabilities:" -parsed_digest = Protocol::HTTP::Header::Digest.new("sha-256=#{sha256_digest}, md5=#{md5_digest}") -entries = parsed_digest.entries -puts "• Parsed digest entries: #{entries.size}" -puts "• First algorithm: #{entries.first.algorithm}" -puts "• Algorithms: #{entries.map(&:algorithm).join(', ')}" - -parsed_timing = Protocol::HTTP::Header::ServerTiming.new("db;dur=25.4, cache;dur=8.2;desc=\"Redis hit\"") -timing_metrics = parsed_timing.metrics -puts "• Parsed timing metrics: #{timing_metrics.size}" -puts "• First metric: #{timing_metrics.first.name} (#{timing_metrics.first.duration}ms)" From 02958d0d3dfdebbdb3d2dc9828ea2f53d3dec104 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 19 Sep 2025 22:38:23 +1200 Subject: [PATCH 06/14] Add release notes. --- releases.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/releases.md b/releases.md index 9e173c04..3e16a88e 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,52 @@ # Releases +## Unreleased + +### Improved HTTP Trailer Security + +This release introduces significant security improvements for HTTP trailer handling, addressing potential HTTP request smuggling vulnerabilities by implementing a restrictive-by-default policy for trailer headers. + + - **Security-by-default**: HTTP trailers are now validated and restricted by default to prevent HTTP request smuggling attacks. + - Only safe headers are permitted in trailers: + - `date` - Response generation timestamps (safe metadata) + - `digest` - Content integrity verification (safe metadata) + - `etag` - Cache validation tags (safe metadata) + - `server-timing` - Performance metrics (safe metadata) + - All other trailers are ignored by default. + +If you are using this library for gRPC, you will need to use a custom policy to allow the `grpc-status` and `grpc-message` trailers: + +```ruby +module GRPCStatus + def self.new(value) + Integer(value) + end + + def self.trailer? + true + end +end + +module GRPCMessage + def self.new(value) + value + end + + def self.trailer? + true + end +end + +GRPC_POLICY = Protocol::HTTP::Headers::POLICY.dup +GRPC_POLICY['grpc-status'] = GRPCStatus +GRPC_POLICY['grpc-message'] = GRPCMessage + +# Reinterpret the headers using the new policy: +response.headers.policy = GRPC_POLICY +response.headers['grpc-status'] # => 0 +response.headers['grpc-message'] # => "OK" +``` + ## v0.53.0 - Improve consistency of Body `#inspect`. From 1a7d10b1fb7b965ae870d2f4908f99f95c3d0dbf Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 19 Sep 2025 22:54:28 +1200 Subject: [PATCH 07/14] Fix documentation. --- lib/protocol/http/header/accept.rb | 3 +-- lib/protocol/http/header/authorization.rb | 7 +++---- lib/protocol/http/header/connection.rb | 2 +- lib/protocol/http/header/cookie.rb | 2 +- lib/protocol/http/header/date.rb | 2 +- lib/protocol/http/header/digest.rb | 4 ++-- lib/protocol/http/header/etag.rb | 2 +- lib/protocol/http/header/multiple.rb | 2 +- lib/protocol/http/header/server_timing.rb | 4 ++-- lib/protocol/http/header/split.rb | 2 +- lib/protocol/http/header/te.rb | 2 +- lib/protocol/http/header/trailer.rb | 3 +-- lib/protocol/http/header/transfer_encoding.rb | 2 +- 13 files changed, 17 insertions(+), 20 deletions(-) diff --git a/lib/protocol/http/header/accept.rb b/lib/protocol/http/header/accept.rb index 5f9a3b64..d2a40da8 100644 --- a/lib/protocol/http/header/accept.rb +++ b/lib/protocol/http/header/accept.rb @@ -93,8 +93,7 @@ def to_s end # Whether this header is acceptable in HTTP trailers. - # Accept headers in trailers can provide content negotiation hints for subsequent responses. - # @returns [Boolean] false, as Accept headers are generally not needed in trailers. + # @returns [Boolean] `false`, as Accept headers are used for response content negotiation. def self.trailer? false end diff --git a/lib/protocol/http/header/authorization.rb b/lib/protocol/http/header/authorization.rb index 9628d599..edc3ac5d 100644 --- a/lib/protocol/http/header/authorization.rb +++ b/lib/protocol/http/header/authorization.rb @@ -31,13 +31,12 @@ def self.basic(username, password) strict_base64_encoded = ["#{username}:#{password}"].pack("m0") self.new( - "Basic #{strict_base64_encoded}" - ) + "Basic #{strict_base64_encoded}" + ) end # Whether this header is acceptable in HTTP trailers. - # Authorization credentials must not appear in trailers for security reasons. - # @returns [Boolean] false, as authorization headers contain sensitive credentials. + # @returns [Boolean] `false`, as authorization headers are used for request authentication. def self.trailer? false end diff --git a/lib/protocol/http/header/connection.rb b/lib/protocol/http/header/connection.rb index 0485e8fb..1c8a65d7 100644 --- a/lib/protocol/http/header/connection.rb +++ b/lib/protocol/http/header/connection.rb @@ -53,7 +53,7 @@ def upgrade? # Whether this header is acceptable in HTTP trailers. # Connection headers control the current connection and must not appear in trailers. - # @returns [Boolean] false, as connection headers are hop-by-hop and forbidden in trailers. + # @returns [Boolean] `false`, as connection headers are hop-by-hop and forbidden in trailers. def self.trailer? false end diff --git a/lib/protocol/http/header/cookie.rb b/lib/protocol/http/header/cookie.rb index 76b251a9..c23b5305 100644 --- a/lib/protocol/http/header/cookie.rb +++ b/lib/protocol/http/header/cookie.rb @@ -26,7 +26,7 @@ def to_h # Whether this header is acceptable in HTTP trailers. # Cookie headers should not appear in trailers as they contain state information needed early in processing. - # @returns [Boolean] false, as cookie headers are needed during initial request processing. + # @returns [Boolean] `false`, as cookie headers are needed during initial request processing. def self.trailer? false end diff --git a/lib/protocol/http/header/date.rb b/lib/protocol/http/header/date.rb index abeea169..02f8034b 100644 --- a/lib/protocol/http/header/date.rb +++ b/lib/protocol/http/header/date.rb @@ -28,7 +28,7 @@ def to_time # Whether this header is acceptable in HTTP trailers. # Date headers can safely appear in trailers as they provide metadata about response generation. - # @returns [Boolean] true, as date headers are metadata that can be computed after response generation. + # @returns [Boolean] `true`, as date headers are metadata that can be computed after response generation. def self.trailer? true end diff --git a/lib/protocol/http/header/digest.rb b/lib/protocol/http/header/digest.rb index 208a18bf..02ffd681 100644 --- a/lib/protocol/http/header/digest.rb +++ b/lib/protocol/http/header/digest.rb @@ -59,8 +59,8 @@ def entries end end - # Digest headers are perfect for use as trailers since they contain - # integrity hashes that can only be calculated after the entire message body is available. + # Whether this header is acceptable in HTTP trailers. + # @returns [Boolean] `true`, as digest headers contain integrity hashes that can only be calculated after the entire message body is available. def self.trailer? true end diff --git a/lib/protocol/http/header/etag.rb b/lib/protocol/http/header/etag.rb index 3b7f2d49..c4f86f96 100644 --- a/lib/protocol/http/header/etag.rb +++ b/lib/protocol/http/header/etag.rb @@ -28,7 +28,7 @@ def weak? # Whether this header is acceptable in HTTP trailers. # ETag headers can safely appear in trailers as they provide cache validation metadata. - # @returns [Boolean] true, as ETag headers are metadata that can be computed after response generation. + # @returns [Boolean] `true`, as ETag headers are metadata that can be computed after response generation. def self.trailer? true end diff --git a/lib/protocol/http/header/multiple.rb b/lib/protocol/http/header/multiple.rb index 19e8abc9..77140de9 100644 --- a/lib/protocol/http/header/multiple.rb +++ b/lib/protocol/http/header/multiple.rb @@ -28,7 +28,7 @@ def to_s # Whether this header is acceptable in HTTP trailers. # This is a base class for headers with multiple values, default is to disallow in trailers. - # @returns [Boolean] false, as most multiple-value headers should not appear in trailers by default. + # @returns [Boolean] `false`, as most multiple-value headers should not appear in trailers by default. def self.trailer? false end diff --git a/lib/protocol/http/header/server_timing.rb b/lib/protocol/http/header/server_timing.rb index dea3bbeb..b47326bc 100644 --- a/lib/protocol/http/header/server_timing.rb +++ b/lib/protocol/http/header/server_timing.rb @@ -84,8 +84,8 @@ def metrics end end - # Server-Timing headers are safe to use as trailers since they contain - # performance metrics that are typically calculated during response generation. + # Whether this header is acceptable in HTTP trailers. + # @returns [Boolean] `true`, as server-timing headers contain performance metrics that are typically calculated during response generation. def self.trailer? true end diff --git a/lib/protocol/http/header/split.rb b/lib/protocol/http/header/split.rb index 7da1da5f..2301b925 100644 --- a/lib/protocol/http/header/split.rb +++ b/lib/protocol/http/header/split.rb @@ -42,7 +42,7 @@ def to_s # Whether this header is acceptable in HTTP trailers. # This is a base class for comma-separated headers, default is to disallow in trailers. - # @returns [Boolean] false, as most comma-separated headers should not appear in trailers by default. + # @returns [Boolean] `false`, as most comma-separated headers should not appear in trailers by default. def self.trailer? false end diff --git a/lib/protocol/http/header/te.rb b/lib/protocol/http/header/te.rb index 3ea25ea3..9374a29e 100644 --- a/lib/protocol/http/header/te.rb +++ b/lib/protocol/http/header/te.rb @@ -121,7 +121,7 @@ def trailers? # Whether this header is acceptable in HTTP trailers. # TE headers negotiate transfer encodings and must not appear in trailers. - # @returns [Boolean] false, as TE headers are hop-by-hop and control message framing. + # @returns [Boolean] `false`, as TE headers are hop-by-hop and control message framing. def self.trailer? false end diff --git a/lib/protocol/http/header/trailer.rb b/lib/protocol/http/header/trailer.rb index 8a3f0ea9..555d13b8 100644 --- a/lib/protocol/http/header/trailer.rb +++ b/lib/protocol/http/header/trailer.rb @@ -13,8 +13,7 @@ module Header # This isn't a specific header class is a utility for handling headers with comma-separated values, such as `accept`, `cache-control`, and other similar headers. The values are split and stored as an array internally, and serialized back to a comma-separated string when needed. class Trailer < Split # Whether this header is acceptable in HTTP trailers. - # Trailer headers themselves must not appear in trailers to avoid recursive references. - # @returns [Boolean] false, as Trailer headers control trailer processing and must appear before the message body. + # @returns [Boolean] `false`, as Trailer headers control trailer processing and must appear before the message body. def self.trailer? false end diff --git a/lib/protocol/http/header/transfer_encoding.rb b/lib/protocol/http/header/transfer_encoding.rb index e0e49e06..354dce6a 100644 --- a/lib/protocol/http/header/transfer_encoding.rb +++ b/lib/protocol/http/header/transfer_encoding.rb @@ -68,7 +68,7 @@ def identity? # Whether this header is acceptable in HTTP trailers. # Transfer-Encoding headers control message framing and must not appear in trailers. - # @returns [Boolean] false, as Transfer-Encoding headers are hop-by-hop and must precede the message body. + # @returns [Boolean] `false`, as Transfer-Encoding headers are hop-by-hop and must precede the message body. def self.trailer? false end From 5cd6b4ae67be4efe3f53676d71fb3f96b1cf1863 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 19 Sep 2025 22:58:45 +1200 Subject: [PATCH 08/14] New lines. --- lib/protocol/http/header/digest.rb | 2 +- lib/protocol/http/header/te.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/protocol/http/header/digest.rb b/lib/protocol/http/header/digest.rb index 02ffd681..74370bd7 100644 --- a/lib/protocol/http/header/digest.rb +++ b/lib/protocol/http/header/digest.rb @@ -67,4 +67,4 @@ def self.trailer? end end end -end \ No newline at end of file +end diff --git a/lib/protocol/http/header/te.rb b/lib/protocol/http/header/te.rb index 9374a29e..d0dee999 100644 --- a/lib/protocol/http/header/te.rb +++ b/lib/protocol/http/header/te.rb @@ -128,4 +128,4 @@ def self.trailer? end end end -end \ No newline at end of file +end From d19ec0a6e4935aaaccb1b7895bf669eb3bf6aebe Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 19 Sep 2025 23:16:57 +1200 Subject: [PATCH 09/14] More documentation. --- lib/protocol/http/header/te.rb | 2 +- releases.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/protocol/http/header/te.rb b/lib/protocol/http/header/te.rb index d0dee999..9a3e2412 100644 --- a/lib/protocol/http/header/te.rb +++ b/lib/protocol/http/header/te.rb @@ -10,7 +10,7 @@ module Protocol module HTTP module Header - # The `te` header indicates the transfer encodings the client is willing to accept. + # The `te` header indicates the transfer encodings the client is willing to accept. AKA `accept-transfer-encoding`. How we ended up with `te` instead of `accept-transfer-encoding` is a mystery lost to time. # # The `te` header allows a client to indicate which transfer encodings it can handle, and in what order of preference using quality factors. class TE < Split diff --git a/releases.md b/releases.md index 3e16a88e..8702961c 100644 --- a/releases.md +++ b/releases.md @@ -2,6 +2,8 @@ ## Unreleased + - Introduce rich support for `Header::Digest`, `Header::ServerTiming`, `Header::TE`, `Header::Trailer` and `Header::TransferEncoding`. + ### Improved HTTP Trailer Security This release introduces significant security improvements for HTTP trailer handling, addressing potential HTTP request smuggling vulnerabilities by implementing a restrictive-by-default policy for trailer headers. From f4352483121e1049e15cf83127340dcb36d4003f Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 19 Sep 2025 23:21:35 +1200 Subject: [PATCH 10/14] Add guide for headers. --- guides/headers/readme.md | 94 ++++++++++++++++++++++++++++++++++++++++ guides/links.yaml | 10 +++-- 2 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 guides/headers/readme.md diff --git a/guides/headers/readme.md b/guides/headers/readme.md new file mode 100644 index 00000000..518c204d --- /dev/null +++ b/guides/headers/readme.md @@ -0,0 +1,94 @@ +# Headers + +This guide explains how to work with HTTP headers using `protocol-http`. + +## Core Concepts + +`protocol-http` provides several core concepts for working with HTTP headers: + +- A {ruby Protocol::HTTP::Headers} class which represents a collection of HTTP headers with built-in security and policy features. +- Header-specific classes like {ruby Protocol::HTTP::Header::Accept} and {ruby Protocol::HTTP::Header::Authorization} which provide specialized parsing and formatting. +- Trailer security validation to prevent HTTP request smuggling attacks. + +## Usage + +The {Protocol::HTTP::Headers} class provides a comprehensive interface for creating and manipulating HTTP headers: + +```ruby +require 'protocol/http' + +headers = Protocol::HTTP::Headers.new +headers.add('content-type', 'text/html') +headers.add('set-cookie', 'session=abc123') + +# Access headers +content_type = headers['content-type'] # => "text/html" + +# Check if header exists +headers.include?('content-type') # => true +``` + +### Header Policies + +Different header types have different behaviors for merging, validation, and trailer handling: + +```ruby +# Some headers can be specified multiple times +headers.add('set-cookie', 'first=value1') +headers.add('set-cookie', 'second=value2') + +# Others are singletons and will raise errors if duplicated +headers.add('content-length', '100') +# headers.add('content-length', '200') # Would raise DuplicateHeaderError +``` + +### Structured Headers + +Some headers have specialized classes for parsing and formatting: + +```ruby +# Accept header with media ranges +accept = Protocol::HTTP::Header::Accept.new('text/html,application/json;q=0.9') +media_ranges = accept.media_ranges + +# Authorization header +auth = Protocol::HTTP::Header::Authorization.basic('username', 'password') +# => "Basic dXNlcm5hbWU6cGFzc3dvcmQ=" +``` + +### Trailer Security + +HTTP trailers are headers that appear after the message body. For security reasons, only certain headers are allowed in trailers: + +```ruby +# Working with trailers +headers = Protocol::HTTP::Headers.new([ + ['content-type', 'text/html'], + ['content-length', '1000'] +]) + +# Start trailer section +headers.trailer! + +# These will be allowed (safe metadata) +headers.add('etag', '"12345"') +headers.add('date', Time.now.httpdate) + +# These will be silently ignored for security +headers.add('authorization', 'Bearer token') # Ignored - credential leakage risk +headers.add('connection', 'close') # Ignored - hop-by-hop header +``` + +The trailer security system prevents HTTP request smuggling by restricting which headers can appear in trailers: + +**Allowed headers** (return `true` for `trailer?`): +- `date` - Response generation timestamps. +- `digest` - Content integrity verification. +- `etag` - Cache validation tags. +- `server-timing` - Performance metrics. + +**Forbidden headers** (return `false` for `trailer?`): +- `authorization` - Prevents credential leakage. +- `connection`, `te`, `transfer-encoding` - Hop-by-hop headers that control connection behavior. +- `cookie`, `set-cookie` - State information needed during initial processing. +- `accept` - Content negotiation must occur before response generation. diff --git a/guides/links.yaml b/guides/links.yaml index 5bf3ae45..221cf402 100644 --- a/guides/links.yaml +++ b/guides/links.yaml @@ -2,13 +2,15 @@ getting-started: order: 1 message-body: order: 2 -middleware: +headers: order: 3 -hypertext-references: +middleware: order: 4 -url-parsing: +hypertext-references: order: 5 -streaming: +url-parsing: order: 6 +streaming: + order: 7 design-overview: order: 10 From c649e81618888841939cadbe07d2c891109fb5bb Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 19 Sep 2025 23:54:13 +1200 Subject: [PATCH 11/14] Fix handling of ServerTiming quoting. --- lib/protocol/http/header/server_timing.rb | 19 ++++++++----------- test/protocol/http/header/server_timing.rb | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/lib/protocol/http/header/server_timing.rb b/lib/protocol/http/header/server_timing.rb index b47326bc..5ddf80b7 100644 --- a/lib/protocol/http/header/server_timing.rb +++ b/lib/protocol/http/header/server_timing.rb @@ -26,8 +26,8 @@ class ServerTiming < Split ParseError = Class.new(Error) # https://www.w3.org/TR/server-timing/ - METRIC = /\A(?[a-zA-Z0-9][a-zA-Z0-9_\-]*)(;(?.*))?\z/ - PARAM = /(?dur|desc)=(?[^;,]+|"[^"]*")/ + METRIC = /\A(?[a-zA-Z0-9][a-zA-Z0-9_\-]*)(;(?.*))?\z/ + PARAMETER = /(?dur|desc)=((?#{TOKEN})|(?#{QUOTED_STRING}))/ # A single metric in the Server-Timing header. Metric = Struct.new(:name, :duration, :description) do @@ -58,22 +58,19 @@ def metrics self.map do |value| if match = value.match(METRIC) name = match[:name] - params = match[:params] || "" + parameters = match[:parameters] || "" duration = nil description = nil - params.scan(PARAM) do |key, param_value| + parameters.scan(PARAMETER) do |key, value, quoted_value| + value = QuotedString.unquote(quoted_value) if quoted_value + case key when "dur" - duration = param_value.to_f + duration = value.to_f when "desc" - # Remove quotes if present - if param_value.start_with?('"') && param_value.end_with?('"') - description = param_value[1..-2] - else - description = param_value - end + description = value end end diff --git a/test/protocol/http/header/server_timing.rb b/test/protocol/http/header/server_timing.rb index 22595e1d..9e48f8fe 100644 --- a/test/protocol/http/header/server_timing.rb +++ b/test/protocol/http/header/server_timing.rb @@ -35,6 +35,16 @@ end end + with 'db;dur="53.2"' do + it "can parse metric with quoted duration" do + metrics = header.metrics + expect(metrics.size).to be == 1 + expect(metrics.first.name).to be == "db" + expect(metrics.first.duration).to be == 53.2 + expect(metrics.first.description).to be_nil + end + end + with 'cache;desc="Redis lookup"' do it "can parse metric with description" do metrics = header.metrics @@ -54,6 +64,15 @@ end end + with 'app;dur="12.7";desc="Application logic"' do + it "can parse metric with quoted duration and quoted description" do + metrics = header.metrics + expect(metrics.first.name).to be == "app" + expect(metrics.first.duration).to be == 12.7 + expect(metrics.first.description).to be == "Application logic" + end + end + with "db;dur=45.3, app;dur=12.7;desc=\"Application logic\", cache;desc=\"Cache miss\"" do it "can parse multiple metrics" do metrics = header.metrics From 7c3fa21466178ba742ad41283133a436b759484a Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 19 Sep 2025 23:58:43 +1200 Subject: [PATCH 12/14] Improve test organization. --- test/protocol/http/header/digest.rb | 56 +++++++++--------- test/protocol/http/header/server_timing.rb | 66 +++++++++++----------- test/protocol/http/header/te.rb | 56 +++++++++--------- 3 files changed, 87 insertions(+), 91 deletions(-) diff --git a/test/protocol/http/header/digest.rb b/test/protocol/http/header/digest.rb index f3a8cc6d..2c2cf096 100644 --- a/test/protocol/http/header/digest.rb +++ b/test/protocol/http/header/digest.rb @@ -118,33 +118,6 @@ end end - with "Entry class" do - let(:entry_class) {subject::Entry} - - it "can create entry directly" do - entry = entry_class.new("sha-256", "abc123") - expect(entry.algorithm).to be == "sha-256" - expect(entry.value).to be == "abc123" - expect(entry.to_s).to be == "sha-256=abc123" - end - - it "normalizes algorithm to lowercase" do - entry = entry_class.new("SHA-256", "abc123") - expect(entry.algorithm).to be == "sha-256" - end - - it "handles complex algorithm names" do - entry = entry_class.new("sha-384", "complex-value") - expect(entry.algorithm).to be == "sha-384" - expect(entry.to_s).to be == "sha-384=complex-value" - end - - it "handles base64 padding in values" do - entry = entry_class.new("md5", "abc123==") - expect(entry.value).to be == "abc123==" - end - end - with "algorithm edge cases" do it "handles hyphenated algorithms" do header = subject.new("sha-256=abc123") @@ -158,7 +131,7 @@ expect(entries.first.algorithm).to be == "md5" end end - + with "value edge cases" do it "handles empty values" do header = subject.new("sha-256=") @@ -172,4 +145,29 @@ expect(entries.first.value).to be == "abc+def/123==" end end -end \ No newline at end of file +end + +describe Protocol::HTTP::Header::Digest::Entry do + it "can create entry directly" do + entry = subject.new("sha-256", "abc123") + expect(entry.algorithm).to be == "sha-256" + expect(entry.value).to be == "abc123" + expect(entry.to_s).to be == "sha-256=abc123" + end + + it "normalizes algorithm to lowercase" do + entry = subject.new("SHA-256", "abc123") + expect(entry.algorithm).to be == "sha-256" + end + + it "handles complex algorithm names" do + entry = subject.new("sha-384", "complex-value") + expect(entry.algorithm).to be == "sha-384" + expect(entry.to_s).to be == "sha-384=complex-value" + end + + it "handles base64 padding in values" do + entry = subject.new("md5", "abc123==") + expect(entry.value).to be == "abc123==" + end +end diff --git a/test/protocol/http/header/server_timing.rb b/test/protocol/http/header/server_timing.rb index 9e48f8fe..bf5145e9 100644 --- a/test/protocol/http/header/server_timing.rb +++ b/test/protocol/http/header/server_timing.rb @@ -213,39 +213,37 @@ expect(metrics.first.duration).to be == 123.456789 end end +end + +describe Protocol::HTTP::Header::ServerTiming::Metric do + it "can create metric directly" do + metric = subject.new("test", 123.45, "Test metric") + expect(metric.name).to be == "test" + expect(metric.duration).to be == 123.45 + expect(metric.description).to be == "Test metric" + expect(metric.to_s).to be == "test;dur=123.45;desc=\"Test metric\"" + end - with "Metric class" do - let(:metric_class) {subject::Metric} - - it "can create metric directly" do - metric = metric_class.new("test", 123.45, "Test metric") - expect(metric.name).to be == "test" - expect(metric.duration).to be == 123.45 - expect(metric.description).to be == "Test metric" - expect(metric.to_s).to be == "test;dur=123.45;desc=\"Test metric\"" - end - - it "can create metric with name only" do - metric = metric_class.new("cache") - expect(metric.name).to be == "cache" - expect(metric.duration).to be_nil - expect(metric.description).to be_nil - expect(metric.to_s).to be == "cache" - end - - it "can create metric with duration only" do - metric = metric_class.new("test", 123.45, nil) - expect(metric.to_s).to be == "test;dur=123.45" - end - - it "can create metric with description only" do - metric = metric_class.new("test", nil, "description") - expect(metric.to_s).to be == "test;desc=\"description\"" - end - - it "handles nil values correctly" do - metric = metric_class.new("test", nil, nil) - expect(metric.to_s).to be == "test" - end + it "can create metric with name only" do + metric = subject.new("cache") + expect(metric.name).to be == "cache" + expect(metric.duration).to be_nil + expect(metric.description).to be_nil + expect(metric.to_s).to be == "cache" + end + + it "can create metric with duration only" do + metric = subject.new("test", 123.45, nil) + expect(metric.to_s).to be == "test;dur=123.45" + end + + it "can create metric with description only" do + metric = subject.new("test", nil, "description") + expect(metric.to_s).to be == "test;desc=\"description\"" + end + + it "handles nil values correctly" do + metric = subject.new("test", nil, nil) + expect(metric.to_s).to be == "test" end -end \ No newline at end of file +end diff --git a/test/protocol/http/header/te.rb b/test/protocol/http/header/te.rb index 6473904e..9e5a0a80 100644 --- a/test/protocol/http/header/te.rb +++ b/test/protocol/http/header/te.rb @@ -98,37 +98,37 @@ end end - with "TransferCoding struct" do - it "handles quality factor conversion" do - coding = Protocol::HTTP::Header::TE::TransferCoding.new("gzip", "0.8") - expect(coding.quality_factor).to be == 0.8 - end - - it "defaults quality factor to 1.0" do - coding = Protocol::HTTP::Header::TE::TransferCoding.new("gzip", nil) - expect(coding.quality_factor).to be == 1.0 - end - - it "serializes with quality factor" do - coding = Protocol::HTTP::Header::TE::TransferCoding.new("gzip", "0.8") - expect(coding.to_s).to be == "gzip;q=0.8" - end - - it "serializes without quality factor when 1.0" do - coding = Protocol::HTTP::Header::TE::TransferCoding.new("gzip", nil) - expect(coding.to_s).to be == "gzip" - end - - it "compares by quality factor" do - high = Protocol::HTTP::Header::TE::TransferCoding.new("gzip", "0.9") - low = Protocol::HTTP::Header::TE::TransferCoding.new("deflate", "0.5") - expect(high <=> low).to be == -1 # high quality first - end - end - with ".trailer?" do it "should be forbidden in trailers" do expect(subject).not.to be(:trailer?) end end +end + +describe Protocol::HTTP::Header::TE::TransferCoding do + it "handles quality factor conversion" do + coding = subject.new("gzip", "0.8") + expect(coding.quality_factor).to be == 0.8 + end + + it "defaults quality factor to 1.0" do + coding = subject.new("gzip", nil) + expect(coding.quality_factor).to be == 1.0 + end + + it "serializes with quality factor" do + coding = subject.new("gzip", "0.8") + expect(coding.to_s).to be == "gzip;q=0.8" + end + + it "serializes without quality factor when 1.0" do + coding = subject.new("gzip", nil) + expect(coding.to_s).to be == "gzip" + end + + it "compares by quality factor" do + high = subject.new("gzip", "0.9") + low = subject.new("deflate", "0.5") + expect(high <=> low).to be == -1 # high quality first + end end \ No newline at end of file From cb7eaf9bb2c88b322f9baf171dc9d23730ff8b9f Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 20 Sep 2025 00:04:33 +1200 Subject: [PATCH 13/14] New lines. --- test/protocol/http/header/te.rb | 4 ++-- test/protocol/http/header/trailer.rb | 4 ++-- test/protocol/http/header/transfer_encoding.rb | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/protocol/http/header/te.rb b/test/protocol/http/header/te.rb index 9e5a0a80..d2e749b4 100644 --- a/test/protocol/http/header/te.rb +++ b/test/protocol/http/header/te.rb @@ -68,7 +68,7 @@ end end - with "" do + with "empty header value" do let(:header) {subject.new} it "handles empty TE header" do @@ -131,4 +131,4 @@ low = subject.new("deflate", "0.5") expect(high <=> low).to be == -1 # high quality first end -end \ No newline at end of file +end diff --git a/test/protocol/http/header/trailer.rb b/test/protocol/http/header/trailer.rb index ae817aa3..9250223c 100644 --- a/test/protocol/http/header/trailer.rb +++ b/test/protocol/http/header/trailer.rb @@ -49,7 +49,7 @@ end end - with "" do + with "empty header value" do let(:header) {subject.new} it "handles empty trailer" do @@ -73,4 +73,4 @@ expect(subject).not.to be(:trailer?) end end -end \ No newline at end of file +end diff --git a/test/protocol/http/header/transfer_encoding.rb b/test/protocol/http/header/transfer_encoding.rb index 0e6066b2..2cfd7e30 100644 --- a/test/protocol/http/header/transfer_encoding.rb +++ b/test/protocol/http/header/transfer_encoding.rb @@ -48,7 +48,7 @@ end end - with "" do + with "empty header value" do let(:header) {subject.new} it "handles empty transfer encoding" do @@ -74,4 +74,4 @@ expect(subject).not.to be(:trailer?) end end -end \ No newline at end of file +end From 31d874e271c0cbad1d95577a779418f6986c2db6 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 20 Sep 2025 00:04:55 +1200 Subject: [PATCH 14/14] RuboCop. --- test/protocol/http/header/digest.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/protocol/http/header/digest.rb b/test/protocol/http/header/digest.rb index 2c2cf096..f49d168a 100644 --- a/test/protocol/http/header/digest.rb +++ b/test/protocol/http/header/digest.rb @@ -131,7 +131,7 @@ expect(entries.first.algorithm).to be == "md5" end end - + with "value edge cases" do it "handles empty values" do header = subject.new("sha-256=")