diff --git a/lib/protocol/http/header/cookie.rb b/lib/protocol/http/header/cookie.rb index f86c97f..adc441a 100644 --- a/lib/protocol/http/header/cookie.rb +++ b/lib/protocol/http/header/cookie.rb @@ -3,7 +3,6 @@ # Released under the MIT License. # Copyright, 2019-2026, by Samuel Williams. -require_relative "multiple" require_relative "../cookie" module Protocol @@ -12,7 +11,40 @@ module Header # The `cookie` header contains stored HTTP cookies previously sent by the server with the `set-cookie` header. # # It is used by clients to send key-value pairs representing stored cookies back to the server. - class Cookie < Multiple + # Multiple cookies within a single `Cookie` header are joined with `"; "` per RFC 6265. + class Cookie < Array + # Parses a raw header value. + # + # @parameter value [String] a single raw header value. + # @returns [Cookie] a new instance containing the parsed value. + def self.parse(value) + self.new([value]) + end + + # Coerces a value into a parsed header object. + # + # @parameter value [String | Array] the value to coerce. + # @returns [Cookie] a parsed header object. + def self.coerce(value) + case value + when Array + self.new(value.map(&:to_s)) + else + self.parse(value.to_s) + end + end + + # Initializes the cookie header with the given values. + # + # @parameter value [Array | Nil] an array of cookie strings, or `nil` for an empty header. + def initialize(value = nil) + super() + + if value + self.concat(value) + end + end + # Parses the `cookie` header into a hash of cookie names and their corresponding cookie objects. # # @returns [Hash(String, HTTP::Cookie)] a hash where keys are cookie names and values are {HTTP::Cookie} objects. @@ -24,9 +56,9 @@ def to_h cookies.map{|cookie| [cookie.name, cookie]}.to_h end - # Serializes the `cookie` header by joining individual cookie strings with semicolons. + # Serializes the `cookie` header by joining individual cookie strings with `"; "` per RFC 6265. def to_s - join(";") + join("; ") end # Whether this header is acceptable in HTTP trailers. @@ -36,12 +68,9 @@ def self.trailer? false end end - - # The `set-cookie` header sends cookies from the server to the user agent. - # - # It is used to store cookies on the client side, which are then sent back to the server in subsequent requests using the `cookie` header. - class SetCookie < Cookie - end end end end + +# Preserve the existing behavior where requiring this file also defines SetCookie. +require_relative "set_cookie" diff --git a/lib/protocol/http/header/set_cookie.rb b/lib/protocol/http/header/set_cookie.rb new file mode 100644 index 0000000..dd29292 --- /dev/null +++ b/lib/protocol/http/header/set_cookie.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2019-2026, by Samuel Williams. + +require_relative "multiple" +require_relative "../cookie" + +module Protocol + module HTTP + module Header + # The `set-cookie` header sends cookies from the server to the user agent. + # + # Each `Set-Cookie` header must be a separate header field — they cannot be combined. + # It is used to store cookies on the client side, which are then sent back to the server + # in subsequent requests using the `cookie` header. + class SetCookie < Multiple + # Parses the `set-cookie` headers into a hash of cookie names and their corresponding cookie objects. + # + # @returns [Hash(String, HTTP::Cookie)] a hash where keys are cookie names and values are {HTTP::Cookie} objects. + def to_h + cookies = self.collect do |string| + HTTP::Cookie.parse(string) + end + + cookies.map{|cookie| [cookie.name, cookie]}.to_h + end + + # Whether this header is acceptable in HTTP trailers. + # @returns [Boolean] `false`, as set-cookie headers are needed during initial response processing. + def self.trailer? + false + end + end + end + end +end diff --git a/lib/protocol/http/headers.rb b/lib/protocol/http/headers.rb index 76dcb5a..b3d634a 100644 --- a/lib/protocol/http/headers.rb +++ b/lib/protocol/http/headers.rb @@ -9,6 +9,7 @@ require_relative "header/multiple" require_relative "header/cookie" +require_relative "header/set_cookie" require_relative "header/connection" require_relative "header/cache_control" require_relative "header/etag" @@ -300,7 +301,13 @@ def []=(key, value) @indexed[key] = value end - @fields << [key, value.to_s] + if value.is_a?(Multiple) + value.each do |v| + @fields << [key, v.to_s] + end + else + @fields << [key, value.to_s] + end end # Get the value of the specified header key. diff --git a/releases.md b/releases.md index d982394..3180bbe 100644 --- a/releases.md +++ b/releases.md @@ -3,6 +3,7 @@ ## Unreleased - Improve `Accept` header parsing for quoted pairs, malformed parameters, invalid wildcards, and invalid quality factors. + - Emit multiple `Set-Cookie` values as separate header fields and delimit combined `Cookie` values with a space after each semicolon. ## v0.64.0 diff --git a/test/protocol/http/header/cookie.rb b/test/protocol/http/header/cookie.rb index 7b108dc..d27a3ed 100644 --- a/test/protocol/http/header/cookie.rb +++ b/test/protocol/http/header/cookie.rb @@ -10,6 +10,12 @@ let(:header) {subject.parse(description)} let(:cookies) {header.to_h} + it "can coerce a single value" do + header = subject.coerce("session=abc123") + + expect(header).to be == ["session=abc123"] + end + with "session=123; secure" do it "can parse cookies" do expect(cookies).to have_keys("session") @@ -56,8 +62,8 @@ cookie end - it "joins cookies with semicolons without spaces" do - expect(header.to_s).to be == "session=abc123;user_id=42;token=xyz789" + it "joins cookies with semicolons and spaces per RFC 6265" do + expect(header.to_s).to be == "session=abc123; user_id=42; token=xyz789" end end end diff --git a/test/protocol/http/header/set_cookie.rb b/test/protocol/http/header/set_cookie.rb new file mode 100644 index 0000000..103b596 --- /dev/null +++ b/test/protocol/http/header/set_cookie.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/http/header/set_cookie" + +describe Protocol::HTTP::Header::SetCookie do + let(:header) do + subject.coerce([ + "session=abc123; Path=/", + "theme=dark; HttpOnly", + ]) + end + + it "represents values which must be emitted as separate fields" do + expect(header).to be_a(Protocol::HTTP::Header::Multiple) + expect(header).to be == ["session=abc123; Path=/", "theme=dark; HttpOnly"] + end + + it "can extract parsed cookies" do + expect(header.to_h).to have_keys("session", "theme") + end +end diff --git a/test/protocol/http/headers.rb b/test/protocol/http/headers.rb index e5bb9b2..7a35956 100644 --- a/test/protocol/http/headers.rb +++ b/test/protocol/http/headers.rb @@ -212,6 +212,35 @@ expect(headers["accept-encoding"]).to be(:include?, "deflate") end + it "joins multiple cookie values into one field" do + headers["cookie"] = ["session=abc123", "theme=dark"] + + expect(headers.fields.select{|key, value| key == "cookie"}).to be == [ + ["cookie", "session=abc123; theme=dark"], + ] + end + + it "adds multiple set-cookie values as separate fields" do + headers["set-cookie"] = ["session=abc123; Path=/", "theme=dark; HttpOnly"] + + expect(headers.fields.select{|key, value| key == "set-cookie"}).to be == [ + ["set-cookie", "session=abc123; Path=/"], + ["set-cookie", "theme=dark; HttpOnly"], + ] + expect(headers["set-cookie"]).to be == ["session=abc123; Path=/", "theme=dark; HttpOnly"] + end + + it "adds multiple set-cookie values after indexing headers" do + headers.to_h + headers["set-cookie"] = ["session=abc123; Path=/", "theme=dark; HttpOnly"] + + expect(headers.fields.select{|key, value| key == "set-cookie"}).to be == [ + ["set-cookie", "session=abc123; Path=/"], + ["set-cookie", "theme=dark; HttpOnly"], + ] + expect(headers["set-cookie"]).to be == ["session=abc123; Path=/", "theme=dark; HttpOnly"] + end + it "can add field with indexed hash" do expect(headers.to_h).not.to be(:empty?) @@ -600,7 +629,7 @@ def self.trailer? with "set-cookie" do it "can extract parsed cookies" do - expect(headers["set-cookie"]).to be_a(Protocol::HTTP::Header::Cookie) + expect(headers["set-cookie"]).to be_a(Protocol::HTTP::Header::SetCookie) end end