diff --git a/lib/nack.rb b/lib/nack.rb index 7e5d307..68e28b5 100644 --- a/lib/nack.rb +++ b/lib/nack.rb @@ -6,5 +6,6 @@ require_relative "nack/mime" require_relative "nack/query_parser" require_relative "nack/request" +require_relative "nack/response" require_relative "nack/spec" require_relative "nack/utils" diff --git a/lib/nack/response.rb b/lib/nack/response.rb new file mode 100644 index 0000000..10d7a7c --- /dev/null +++ b/lib/nack/response.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +module Nack + class Response + COOKIE_NAME = /\A[0-9A-Za-z!#$%&'*+\-.^_`|~]+\z/ + COOKIE_VALUE = /\A("?)[!#-+\--:<-\[\]-~]*\1\z/ + IMF_FIXDATE = "%a, %d %b %Y %H:%M:%S GMT" + COOKIE_DOMAIN_LABEL = /[0-9A-Za-z](?:[0-9A-Za-z-]{0,61}[0-9A-Za-z])?/ + COOKIE_DOMAIN = /\A#{COOKIE_DOMAIN_LABEL}(?:\.#{COOKIE_DOMAIN_LABEL})*\z/ + COOKIE_PATH = /\A[ -:<-~]*\z/ + COOKIE_SAME_SITE = /\A(?:strict|lax|none)\z/i + + class BodyWithTrailers + attr_reader :trailers + + def initialize(body, trailers) + @body = body + @trailers = trailers + end + + def each + yield @body + end + end + private_constant :BodyWithTrailers + + attr_accessor :status + attr_reader :headers, :body + + def initialize(status = 200, headers = [].freeze, body = nil) + @status = status + @headers = Headers.new(headers) + @body = body ? body.b : "".b + end + + def content_type + headers["content-type"] + end + + def content_type=(content_type) + content_type ? headers["content-type"] = content_type : headers.delete("content-type") + end + + def content_length + content_length = headers["content-length"] + content_length.to_i if Spec.valid_content_length?(content_length) + end + + def content_length=(content_length) + if content_length.nil? + headers.delete("content-length") + elsif content_length.is_a?(Integer) && content_length >= 0 + headers["content-length"] = content_length.to_s + else + raise SpecError, "invalid content length: #{content_length.inspect}" + end + end + + def location + headers["location"] + end + + def location=(location) + location ? headers["location"] = location : headers.delete("location") + end + + def redirect(location, status = 302) + self.location = location + self.status = status + end + + def set_cookie(name, value, expires: nil, max_age: nil, domain: nil, path: nil, secure: false, http_only: false, same_site: nil) + expires = expires&.getutc + raise ArgumentError, "invalid cookie name: #{name.inspect}" unless COOKIE_NAME.match?(name) + raise ArgumentError, "invalid cookie value: #{value.inspect}" unless COOKIE_VALUE.match?(value) + raise ArgumentError, "invalid cookie expires: #{expires.inspect}" unless expires.nil? || (1900..9999).cover?(expires.year) + raise ArgumentError, "invalid cookie max age: #{max_age.inspect}" if max_age && !(max_age.is_a?(Integer) && max_age.positive?) + raise ArgumentError, "invalid cookie domain: #{domain.inspect}" unless domain.nil? || COOKIE_DOMAIN.match?(domain) + raise ArgumentError, "invalid cookie path: #{path.inspect}" unless path.nil? || COOKIE_PATH.match?(path) + raise ArgumentError, "invalid cookie same site: #{same_site.inspect}" unless same_site.nil? || COOKIE_SAME_SITE.match?(same_site) + cookie = "#{name}=#{value}#{ + "; expires=#{expires.strftime(IMF_FIXDATE)}" if expires}#{ + "; max-age=#{max_age}" if max_age}#{ + "; domain=#{domain}" if domain}#{ + "; path=#{path}" if path}#{ + "; secure" if secure}#{ + "; httponly" if http_only}#{ + "; samesite=#{same_site}" if same_site}" + # rfc6265bis section 4.1.1 forbids two set-cookie fields of one name in a response. + headers.delete_if do |field_name, field_value| + field_name == "set-cookie" && field_value.start_with?(name) && field_value.match?(/\G[ \t]*=/, name.size) + end + headers.add("set-cookie", cookie) + end + + def delete_cookie(name, **attributes) + set_cookie(name, "", **attributes, expires: Time.at(0), max_age: nil) + end + + def write(chunk) + @body.append_as_bytes(chunk) + end + + def trailers + @trailers ||= Headers.new + end + + def finish + return [status, headers.to_a, ""] if status == 204 || status == 205 || status == 304 + trailer_fields = @trailers&.to_a + [status, headers.to_a, (trailer_fields.nil? || trailer_fields.empty?) ? body : BodyWithTrailers.new(body, trailer_fields)] + end + end +end diff --git a/test/spec_response.rb b/test/spec_response.rb new file mode 100644 index 0000000..a960314 --- /dev/null +++ b/test/spec_response.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require_relative "helper" + +describe Nack::Response do + it "sets and deletes the content type, content length and location" do + res = Nack::Response.new + res.content_type = "a" + res.content_length = 1 + res.location = "b" + [res.content_type, res.content_length, res.location].must_equal ["a", 1, "b"] + res.content_type = nil + res.content_length = nil + res.location = nil + res.headers.to_a.must_equal [] + end + + it "reads no content length from a malformed field" do + Nack::Response.new(200, [["content-length", "-1"]]).content_length.must_be_nil + end + + it "rejects a content length other than a non-negative Integer" do + res = Nack::Response.new + -> { res.content_length = -1 }.must_raise Nack::SpecError + -> { res.content_length = "1" }.must_raise Nack::SpecError + res.content_length = 0 + res.headers.to_a.must_equal [["content-length", "0"]] + end + + it "redirects" do + res = Nack::Response.new + res.redirect("a") + [res.status, res.location].must_equal [302, "a"] + res.redirect("b", 301) + [res.status, res.location].must_equal [301, "b"] + end + + it "rejects a cookie outside the rfc6265bis grammar" do + res = Nack::Response.new + -> { res.set_cookie("", "1") }.must_raise ArgumentError + -> { res.set_cookie("a=a", "1") }.must_raise ArgumentError + -> { res.set_cookie("a", "1;") }.must_raise ArgumentError + -> { res.set_cookie("a", "\"1") }.must_raise ArgumentError + -> { res.set_cookie("a", "1", expires: Time.new(1900, 1, 1, 8, 59, 59, "+09:00")) }.must_raise ArgumentError + -> { res.set_cookie("a", "1", expires: Time.utc(10000)) }.must_raise ArgumentError + -> { res.set_cookie("a", "1", max_age: 0) }.must_raise ArgumentError + -> { res.set_cookie("a", "1", max_age: "1") }.must_raise ArgumentError + -> { res.set_cookie("a", "1", domain: ".b") }.must_raise ArgumentError + -> { res.set_cookie("a", "1", domain: "-b") }.must_raise ArgumentError + -> { res.set_cookie("a", "1", domain: "b-") }.must_raise ArgumentError + -> { res.set_cookie("a", "1", domain: "b" * 64) }.must_raise ArgumentError + -> { res.set_cookie("a", "1", path: ";") }.must_raise ArgumentError + -> { res.set_cookie("a", "1", path: "é") }.must_raise ArgumentError + -> { res.set_cookie("a", "1", same_site: "LaxLax") }.must_raise ArgumentError + res.headers.to_a.must_equal [] + end + + it "sets cookies" do + res = Nack::Response.new + res.set_cookie("a", "\"1\"") + res.set_cookie("b", "", expires: Time.new(1900, 1, 1, 9, 0, 0, "+09:00"), max_age: 1, domain: "0-a.b", path: "a b", secure: true, http_only: true, same_site: "Lax") + res.set_cookie("c", "", expires: Time.utc(9999), domain: "b" * 63) + res.headers.get_all("set-cookie").must_equal [ + "a=\"1\"", + "b=; expires=Mon, 01 Jan 1900 00:00:00 GMT; max-age=1; domain=0-a.b; path=a b; secure; httponly; samesite=Lax", + "c=; expires=Fri, 01 Jan 9999 00:00:00 GMT; domain=#{"b" * 63}" + ] + end + + it "replaces a cookie of the same name, keeping the other fields in order" do + res = Nack::Response.new(200, [["set-cookie", "a=1"], ["set-cookie", "a =2"], ["set-cookie", "a\t=3"], ["set-cookie", "ab=4"], ["set-cookie", "a b=5"], ["b", "a=6"]]) + res.set_cookie("a", "7") + res.headers.to_a.must_equal [["set-cookie", "ab=4"], ["set-cookie", "a b=5"], ["b", "a=6"], ["set-cookie", "a=7"]] + end + + it "deletes a cookie with an expiry in the past" do + res = Nack::Response.new + res.delete_cookie("a", max_age: 1, path: "b") + res.headers.to_a.must_equal [["set-cookie", "a=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=b"]] + end + + it "finishes into a status, fields and the written body" do + res = Nack::Response.new(201, [["a", "1"]], "b") + res.write("\xFF".b) + res.write("é") + res.finish.must_equal [201, [["a", "1"]], "b\xFF\xC3\xA9".b] + Nack::Response.new.finish.must_equal [200, [], ""] + end + + it "drops the body and trailers of a 204, a 205 and a 304 but not of a 101" do + res = Nack::Response.new(204, [], "a") + res.trailers.add("b", "1") + res.finish.must_equal [204, [], ""] + Nack::Response.new(205, [], "a").finish.must_equal [205, [], ""] + Nack::Response.new(304, [], "a").finish.must_equal [304, [], ""] + Nack::Response.new(101, [], "a").finish.must_equal [101, [], "a"] + end + + it "finishes into a String body when no trailer is added" do + res = Nack::Response.new(200, [], "a") + res.trailers + res.finish.must_equal [200, [], "a"] + end + + it "carries trailers on a body responding to #each" do + res = Nack::Response.new(200, [], "a") + res.trailers.add("b", "1") + _, _, body = res.finish + body.to_enum(:each).to_a.must_equal ["a"] + body.trailers.must_equal [["b", "1"]] + end +end