diff --git a/lib/protocol/http/header/range.rb b/lib/protocol/http/header/range.rb new file mode 100644 index 0000000..c017668 --- /dev/null +++ b/lib/protocol/http/header/range.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "../error" + +module Protocol + module HTTP + module Header + # Represents a `range` request header. + class Range + ParseError = Class.new(Error) + + TOKEN = /[!#$%&'*+\-.0-9A-Z^_`a-z|~]+/ + HEADER = /\A(?#{TOKEN})=(?.*)\z/ + BYTE_RANGE = /\A(?:(?\d+)-(?\d*)|-(?\d+))\z/ + OTHER_RANGE = /\A[\x21-\x2B\x2D-\x7E]+\z/ + SEPARATOR = /\s*,\s*/ + + # Represents one byte-range-spec or suffix-byte-range-spec. + ByteRange = Struct.new(:first, :last) do + # Parse one byte range. + # @parameter value [String] The byte range to parse. + # @returns [ByteRange] The parsed byte range. + def self.parse(value) + unless match = BYTE_RANGE.match(value) + raise ParseError, "Invalid byte range: #{value.inspect}" + end + + if suffix = match[:suffix] + return self.new(nil, Integer(suffix)) + else + first = Integer(match[:first]) + last = match[:last] + last = last.empty? ? nil : Integer(last) + + if last && last < first + raise ParseError, "Invalid byte range: #{value.inspect}" + end + + return self.new(first, last) + end + end + + # Resolve this byte range against the selected representation size. + # @parameter size [Integer] The size of the selected representation. + # @returns [::Range | Nil] The resolved range, or `nil` when it is unsatisfiable. + def resolve(size) + if first + if first < size + return first..[last || size - 1, size - 1].min + end + elsif last > 0 && size > 0 + return [0, size - last].max..size - 1 + end + + return nil + end + + # Convert this byte range to its wire representation. + # @returns [String] The serialized byte range. + def to_s + if first + "#{first}-#{last}" + else + "-#{last}" + end + end + end + + # Parse a raw range header value. + # @parameter value [String] The raw header value. + # @returns [Range] The parsed range header. + def self.parse(value) + unless match = HEADER.match(value) + raise ParseError, "Invalid range header: #{value.inspect}" + end + + unit = match[:unit].downcase + ranges = match[:ranges].split(SEPARATOR, -1) + + if ranges.empty? || ranges.any?(&:empty?) + raise ParseError, "Invalid range set: #{match[:ranges].inspect}" + end + + if unit == "bytes" + ranges.map!{|range| ByteRange.parse(range)} + elsif ranges.any?{|range| !OTHER_RANGE.match?(range)} + raise ParseError, "Invalid range set: #{match[:ranges].inspect}" + end + + return self.new(unit, ranges) + end + + # Coerce a value into a range header. + # @parameter value [Object] The value to coerce. + # @returns [Range] The parsed range header. + def self.coerce(value) + self.parse(value.to_s) + end + + # Initialize a range header. + # @parameter unit [String] The range unit. + # @parameter ranges [Array] The range specifiers. + def initialize(unit, ranges) + @unit = unit + @ranges = ranges + end + + # @attribute [String] The range unit. + attr :unit + + # @attribute [Array] The range specifiers. + attr :ranges + + # Whether this header contains byte ranges. + # @returns [Boolean] Whether the range unit is `bytes`. + def bytes? + @unit == "bytes" + end + + # Resolve all byte ranges against the selected representation size. + # @parameter size [Integer] The size of the selected representation. + # @returns [Array(::Range)] The satisfiable byte ranges. + def resolve(size) + unless bytes? + raise ArgumentError, "Cannot resolve #{@unit.inspect} ranges as byte ranges!" + end + + size = Integer(size) + raise ArgumentError, "Size must not be negative!" if size < 0 + + @ranges.filter_map{|range| range.resolve(size)} + end + + # Combine another raw range header value with this one. + # @parameter value [String] The raw range header value. + def <<(value) + other = self.class.parse(value) + + unless other.unit == @unit + raise ParseError, "Cannot combine range units: #{@unit.inspect} and #{other.unit.inspect}" + end + + @ranges.concat(other.ranges) + + return self + end + + # Convert this header to its wire representation. + # @returns [String] The serialized range header. + def to_s + "#{@unit}=#{@ranges.join(",")}" + end + + # Whether this header is acceptable in HTTP trailers. + # @returns [Boolean] `false`, as range headers apply to a selected representation. + def self.trailer? + false + end + end + end + end +end diff --git a/lib/protocol/http/headers.rb b/lib/protocol/http/headers.rb index b3d634a..f4eaab6 100644 --- a/lib/protocol/http/headers.rb +++ b/lib/protocol/http/headers.rb @@ -18,6 +18,7 @@ require_relative "header/authorization" require_relative "header/date" require_relative "header/priority" +require_relative "header/range" require_relative "header/trailer" require_relative "header/server_timing" require_relative "header/digest" @@ -347,7 +348,7 @@ def merge(headers) "host" => false, "location" => false, "max-forwards" => false, - "range" => false, + "range" => Header::Range, "referer" => false, "retry-after" => false, "server" => false, diff --git a/readme.md b/readme.md index 31716c9..84a298d 100644 --- a/readme.md +++ b/readme.md @@ -30,6 +30,10 @@ Please see the [project documentation](https://socketry.github.io/protocol-http/ Please see the [project releases](https://socketry.github.io/protocol-http/releases/index) for all releases. +### Unreleased + + - Parse and resolve HTTP `Range` header values according to the default headers policy. + ### v0.66.0 - Introduce `Protocol::HTTP::RemoteError` for remote endpoint failures where application processing may have occurred. diff --git a/releases.md b/releases.md index 62dfab4..613016e 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Parse and resolve HTTP `Range` header values according to the default headers policy. + ## v0.66.0 - Introduce `Protocol::HTTP::RemoteError` for remote endpoint failures where application processing may have occurred. diff --git a/test/protocol/http/header/range.rb b/test/protocol/http/header/range.rb new file mode 100644 index 0000000..4ec8220 --- /dev/null +++ b/test/protocol/http/header/range.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/http/header/range" + +describe Protocol::HTTP::Header::Range do + with ".parse" do + it "parses byte ranges" do + header = subject.parse("bytes=0-4, 10-, -5") + + expect(header.unit).to be == "bytes" + expect(header.ranges).to be == [ + subject::ByteRange.new(0, 4), + subject::ByteRange.new(10, nil), + subject::ByteRange.new(nil, 5), + ] + expect(header.to_s).to be == "bytes=0-4,10-,-5" + end + + it "normalizes the unit" do + header = subject.parse("BYTES=0-4") + + expect(header.unit).to be == "bytes" + end + + it "preserves extension ranges" do + header = subject.parse("example=alpha, beta") + + expect(header.unit).to be == "example" + expect(header.ranges).to be == ["alpha", "beta"] + end + + it "rejects malformed headers" do + [ + "bytes", + "bytes=", + "bytes=0-1,", + "bytes=-", + "bytes=4-1", + "bytes=one-two", + "example=alpha beta", + ].each do |value| + expect{subject.parse(value)}.to raise_exception(subject::ParseError) + end + end + end + + with "#resolve" do + it "resolves bounded byte ranges" do + header = subject.parse("bytes=1-4") + + expect(header.resolve(12)).to be == [1..4] + end + + it "resolves open-ended byte ranges" do + header = subject.parse("bytes=8-") + + expect(header.resolve(12)).to be == [8..11] + end + + it "resolves suffix byte ranges" do + header = subject.parse("bytes=-5") + + expect(header.resolve(12)).to be == [7..11] + end + + it "clamps byte ranges to the representation size" do + header = subject.parse("bytes=1-999,-999") + + expect(header.resolve(12)).to be == [1..11, 0..11] + end + + it "omits unsatisfiable byte ranges" do + header = subject.parse("bytes=999-1000,-0") + + expect(header.resolve(12)).to be == [] + end + + it "does not resolve extension ranges as byte ranges" do + header = subject.parse("example=alpha") + + expect{header.resolve(12)}.to raise_exception(ArgumentError) + end + + it "rejects a negative representation size" do + header = subject.parse("bytes=0-1") + + expect{header.resolve(-1)}.to raise_exception(ArgumentError) + end + end + + with "#<<" do + it "combines ranges with the same unit" do + header = subject.parse("bytes=0-1") + header << "bytes=4-5" + + expect(header.resolve(10)).to be == [0..1, 4..5] + end + + it "rejects ranges with a different unit" do + header = subject.parse("bytes=0-1") + + expect{header << "example=alpha"}.to raise_exception(subject::ParseError) + end + end + + with ".trailer?" do + it "is not allowed in trailers" do + expect(subject).not.to be(:trailer?) + end + end +end diff --git a/test/protocol/http/headers.rb b/test/protocol/http/headers.rb index 7a35956..e39d3aa 100644 --- a/test/protocol/http/headers.rb +++ b/test/protocol/http/headers.rb @@ -174,11 +174,37 @@ end with "#to_h" do + it "parses range headers according to the policy" do + headers = subject[[["range", "bytes=1-4"]]] + range = headers["range"] + + expect(range).to be_a(Protocol::HTTP::Header::Range) + expect(range.resolve(12)).to be == [1..4] + end + + it "combines repeated range headers according to the policy" do + headers = subject[[ + ["range", "bytes=1-4"], + ["range", "bytes=8-"], + ]] + + expect(headers["range"].resolve(12)).to be == [1..4, 8..11] + end + it "should generate array values for duplicate keys" do expect(headers.to_h["set-cookie"]).to be == ["hello=world", "foo=bar"] end end + with "#[]=" do + it "coerces range header values according to the policy" do + headers["range"] = "bytes=-4" + + expect(headers["range"]).to be_a(Protocol::HTTP::Header::Range) + expect(headers["range"].resolve(12)).to be == [8..11] + end + end + with "#inspect" do it "should generate a string representation" do expect(headers.inspect).to be == "#"