lib/rack/lint.rb in rack-3.0.10 vs lib/rack/lint.rb in rack-3.1.0
- old
+ new
@@ -1,17 +1,23 @@
# frozen_string_literal: true
require 'forwardable'
+require 'uri'
require_relative 'constants'
require_relative 'utils'
module Rack
# Rack::Lint validates your application and the requests and
# responses according to the Rack spec.
class Lint
+ REQUEST_PATH_ORIGIN_FORM = /\A\/[^#]*\z/
+ REQUEST_PATH_ABSOLUTE_FORM = /\A#{URI::DEFAULT_PARSER.make_regexp}\z/
+ REQUEST_PATH_AUTHORITY_FORM = /\A(.*?)(:\d*)\z/
+ REQUEST_PATH_ASTERISK_FORM = '*'
+
def initialize(app)
@app = app
end
# :stopdoc:
@@ -54,13 +60,10 @@
def response
## It takes exactly one argument, the *environment*
raise LintError, "No env given" unless @env
check_environment(@env)
- @env[RACK_INPUT] = InputWrapper.new(@env[RACK_INPUT])
- @env[RACK_ERRORS] = ErrorWrapper.new(@env[RACK_ERRORS])
-
## and returns a non-frozen Array of exactly three values:
@response = @app.call(@env)
raise LintError, "response is not an Array, but #{@response.class}" unless @response.kind_of? Array
raise LintError, "response is frozen" if @response.frozen?
raise LintError, "response array has #{@response.size} elements instead of 3" unless @response.size == 3
@@ -76,12 +79,13 @@
if hijack_proc
@headers[RACK_HIJACK] = hijack_proc
end
## and the *body*.
- check_content_type(@status, @headers)
- check_content_length(@status, @headers)
+ check_content_type_header(@status, @headers)
+ check_content_length_header(@status, @headers)
+ check_rack_protocol_header(@status, @headers)
@head_request = @env[REQUEST_METHOD] == HEAD
@lint = (@env['rack.lint'] ||= []) << self
if (@env['rack.lint.body_iteration'] ||= 0) > 0
@@ -177,10 +181,20 @@
## <tt>rack.hijack</tt>:: See below, if present, an object responding
## to +call+ that is used to perform a full
## hijack.
+ ## <tt>rack.protocol</tt>:: An optional +Array+ of +String+, containing
+ ## the protocols advertised by the client in
+ ## the +upgrade+ header (HTTP/1) or the
+ ## +:protocol+ pseudo-header (HTTP/2).
+ if protocols = @env['rack.protocol']
+ unless protocols.is_a?(Array) && protocols.all?{|protocol| protocol.is_a?(String)}
+ raise LintError, "rack.protocol must be an Array of Strings"
+ end
+ end
+
## Additional environment specifications have approved to
## standardized middleware APIs. None of these are required to
## be implemented by the server.
## <tt>rack.session</tt>:: A hash-like interface for storing
@@ -263,15 +277,13 @@
## environment, too. The keys must contain at least one dot,
## and should be prefixed uniquely. The prefix <tt>rack.</tt>
## is reserved for use with the Rack core distribution and other
## accepted specifications and must not be used otherwise.
##
-
- %w[REQUEST_METHOD SERVER_NAME QUERY_STRING SERVER_PROTOCOL
- rack.input rack.errors].each { |header|
+ %w[REQUEST_METHOD SERVER_NAME QUERY_STRING SERVER_PROTOCOL rack.errors].each do |header|
raise LintError, "env missing required key #{header}" unless env.include? header
- }
+ end
## The <tt>SERVER_PORT</tt> must be an Integer if set.
server_port = env["SERVER_PORT"]
unless server_port.nil? || (Integer(server_port) rescue false)
raise LintError, "env[SERVER_PORT] is not an Integer"
@@ -291,15 +303,10 @@
server_protocol = env['SERVER_PROTOCOL']
unless %r{HTTP/\d(\.\d)?}.match?(server_protocol)
raise LintError, "env[SERVER_PROTOCOL] does not match HTTP/\\d(\\.\\d)?"
end
- ## If the <tt>HTTP_VERSION</tt> is present, it must equal the <tt>SERVER_PROTOCOL</tt>.
- if env['HTTP_VERSION'] && env['HTTP_VERSION'] != server_protocol
- raise LintError, "env[HTTP_VERSION] does not equal env[SERVER_PROTOCOL]"
- end
-
## The environment must not contain the keys
## <tt>HTTP_CONTENT_TYPE</tt> or <tt>HTTP_CONTENT_LENGTH</tt>
## (use the versions without <tt>HTTP_</tt>).
%w[HTTP_CONTENT_TYPE HTTP_CONTENT_LENGTH].each { |header|
if env.include? header
@@ -326,30 +333,61 @@
## * <tt>rack.url_scheme</tt> must either be +http+ or +https+.
unless %w[http https].include?(env[RACK_URL_SCHEME])
raise LintError, "rack.url_scheme unknown: #{env[RACK_URL_SCHEME].inspect}"
end
- ## * There must be a valid input stream in <tt>rack.input</tt>.
- check_input env[RACK_INPUT]
+ ## * There may be a valid input stream in <tt>rack.input</tt>.
+ if rack_input = env[RACK_INPUT]
+ check_input_stream(rack_input)
+ @env[RACK_INPUT] = InputWrapper.new(rack_input)
+ end
+
## * There must be a valid error stream in <tt>rack.errors</tt>.
- check_error env[RACK_ERRORS]
+ rack_errors = env[RACK_ERRORS]
+ check_error_stream(rack_errors)
+ @env[RACK_ERRORS] = ErrorWrapper.new(rack_errors)
+
## * There may be a valid hijack callback in <tt>rack.hijack</tt>
check_hijack env
+ ## * There may be a valid early hints callback in <tt>rack.early_hints</tt>
+ check_early_hints env
## * The <tt>REQUEST_METHOD</tt> must be a valid token.
unless env[REQUEST_METHOD] =~ /\A[0-9A-Za-z!\#$%&'*+.^_`|~-]+\z/
raise LintError, "REQUEST_METHOD unknown: #{env[REQUEST_METHOD].dump}"
end
## * The <tt>SCRIPT_NAME</tt>, if non-empty, must start with <tt>/</tt>
if env.include?(SCRIPT_NAME) && env[SCRIPT_NAME] != "" && env[SCRIPT_NAME] !~ /\A\//
raise LintError, "SCRIPT_NAME must start with /"
end
- ## * The <tt>PATH_INFO</tt>, if non-empty, must start with <tt>/</tt>
- if env.include?(PATH_INFO) && env[PATH_INFO] != "" && env[PATH_INFO] !~ /\A\//
- raise LintError, "PATH_INFO must start with /"
+
+ ## * The <tt>PATH_INFO</tt>, if provided, must be a valid request target.
+ if env.include?(PATH_INFO)
+ case env[PATH_INFO]
+ when REQUEST_PATH_ASTERISK_FORM
+ ## * Only <tt>OPTIONS</tt> requests may have <tt>PATH_INFO</tt> set to <tt>*</tt> (asterisk-form).
+ unless env[REQUEST_METHOD] == OPTIONS
+ raise LintError, "Only OPTIONS requests may have PATH_INFO set to '*' (asterisk-form)"
+ end
+ when REQUEST_PATH_AUTHORITY_FORM
+ ## * Only <tt>CONNECT</tt> requests may have <tt>PATH_INFO</tt> set to an authority (authority-form). Note that in HTTP/2+, the authority-form is not a valid request target.
+ unless env[REQUEST_METHOD] == CONNECT
+ raise LintError, "Only CONNECT requests may have PATH_INFO set to an authority (authority-form)"
+ end
+ when REQUEST_PATH_ABSOLUTE_FORM
+ ## * <tt>CONNECT</tt> and <tt>OPTIONS</tt> requests must not have <tt>PATH_INFO</tt> set to a URI (absolute-form).
+ if env[REQUEST_METHOD] == CONNECT || env[REQUEST_METHOD] == OPTIONS
+ raise LintError, "CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form)"
+ end
+ when REQUEST_PATH_ORIGIN_FORM
+ ## * Otherwise, <tt>PATH_INFO</tt> must start with a <tt>/</tt> and must not include a fragment part starting with '#' (origin-form).
+ else
+ raise LintError, "PATH_INFO must start with a '/' and must not include a fragment part starting with '#' (origin-form)"
+ end
end
+
## * The <tt>CONTENT_LENGTH</tt>, if given, must consist of digits only.
if env.include?("CONTENT_LENGTH") && env["CONTENT_LENGTH"] !~ /\A\d+\z/
raise LintError, "Invalid CONTENT_LENGTH: #{env["CONTENT_LENGTH"]}"
end
@@ -382,13 +420,13 @@
##
## === The Input Stream
##
## The input stream is an IO-like object which contains the raw HTTP
## POST data.
- def check_input(input)
+ def check_input_stream(input)
## When applicable, its external encoding must be "ASCII-8BIT" and it
- ## must be opened in binary mode, for Ruby 1.9 compatibility.
+ ## must be opened in binary mode.
if input.respond_to?(:external_encoding) && input.external_encoding != Encoding::ASCII_8BIT
raise LintError, "rack.input #{input} does not have ASCII-8BIT as its external encoding"
end
if input.respond_to?(:binmode?) && !input.binmode?
raise LintError, "rack.input #{input} is not opened in binary mode"
@@ -416,11 +454,11 @@
raise LintError, "rack.input#gets didn't return a String"
end
v
end
- ## * +read+ behaves like IO#read.
+ ## * +read+ behaves like <tt>IO#read</tt>.
## Its signature is <tt>read([length, [buffer]])</tt>.
##
## If given, +length+ must be a non-negative Integer (>= 0) or +nil+,
## and +buffer+ must be a String and may not be nil.
##
@@ -476,21 +514,21 @@
end
yield line
}
end
- ## * +close+ can be called on the input stream to indicate that the
- ## any remaining input is not needed.
+ ## * +close+ can be called on the input stream to indicate that
+ ## any remaining input is not needed.
def close(*args)
@input.close(*args)
end
end
##
## === The Error Stream
##
- def check_error(error)
+ def check_error_stream(error)
## The error stream must respond to +puts+, +write+ and +flush+.
[:puts, :write, :flush].each { |method|
unless error.respond_to? method
raise LintError, "rack.error #{error} does not respond to ##{method}"
end
@@ -607,10 +645,34 @@
end
nil
end
+ ##
+ ## === Early Hints
+ ##
+ ## The application or any middleware may call the <tt>rack.early_hints</tt>
+ ## with an object which would be valid as the headers of a Rack response.
+ def check_early_hints(env)
+ if env[RACK_EARLY_HINTS]
+ ##
+ ## If <tt>rack.early_hints</tt> is present, it must respond to #call.
+ unless env[RACK_EARLY_HINTS].respond_to?(:call)
+ raise LintError, "rack.early_hints must respond to call"
+ end
+
+ original_callback = env[RACK_EARLY_HINTS]
+ env[RACK_EARLY_HINTS] = lambda do |headers|
+ ## If <tt>rack.early_hints</tt> is called, it must be called with
+ ## valid Rack response headers.
+ check_headers(headers)
+ original_callback.call(headers)
+ end
+ end
+ end
+
+ ##
## == The Response
##
## === The Status
##
def check_status(status)
@@ -670,13 +732,13 @@
raise LintError, "invalid header value #{key}: #{value.inspect}"
end
end
##
- ## === The content-type
+ ## ==== The +content-type+ Header
##
- def check_content_type(status, headers)
+ def check_content_type_header(status, headers)
headers.each { |key, value|
## There must not be a <tt>content-type</tt> header key when the +Status+ is 1xx,
## 204, or 304.
if key == "content-type"
if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i
@@ -686,13 +748,13 @@
end
}
end
##
- ## === The content-length
+ ## ==== The +content-length+ Header
##
- def check_content_length(status, headers)
+ def check_content_length_header(status, headers)
headers.each { |key, value|
if key == 'content-length'
## There must not be a <tt>content-length</tt> header key when the
## +Status+ is 1xx, 204, or 304.
if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i
@@ -713,11 +775,34 @@
raise LintError, "content-length header was #{@content_length}, but should be #{size}"
end
end
end
+ ##
+ ## ==== The +rack.protocol+ Header
##
+ def check_rack_protocol_header(status, headers)
+ ## If the +rack.protocol+ header is present, it must be a +String+, and
+ ## must be one of the values from the +rack.protocol+ array from the
+ ## environment.
+ protocol = headers['rack.protocol']
+
+ if protocol
+ request_protocols = @env['rack.protocol']
+
+ if request_protocols.nil?
+ raise LintError, "rack.protocol header is #{protocol.inspect}, but rack.protocol was not set in request!"
+ elsif !request_protocols.include?(protocol)
+ raise LintError, "rack.protocol header is #{protocol.inspect}, but should be one of #{request_protocols.inspect} from the request!"
+ end
+ end
+ end
+ ##
+ ## Setting this value informs the server that it should perform a
+ ## connection upgrade. In HTTP/1, this is done using the +upgrade+
+ ## header. In HTTP/2, this is done by accepting the request.
+ ##
## === The Body
##
## The Body is typically an +Array+ of +String+ instances, an enumerable
## that yields +String+ instances, a +Proc+ instance, or a File-like
## object.
@@ -780,23 +865,20 @@
raise LintError, "Enumerable Body must respond to each" unless @body.respond_to?(:each)
## It must only be called once.
raise LintError, "Response body must only be invoked once (#{@invoked})" unless @invoked.nil?
- ## It must not be called after being closed.
+ ## It must not be called after being closed,
raise LintError, "Response body is already closed" if @closed
@invoked = :each
@body.each do |chunk|
## and must only yield String values.
unless chunk.kind_of? String
raise LintError, "Body yielded non-string value #{chunk.inspect}"
end
- ##
- ## The Body itself should not be an instance of String, as this will
- ## break in Ruby 1.9.
##
## Middleware must not call +each+ directly on the Body.
## Instead, middleware can return a new Body that calls +each+ on the
## original Body, yielding at least once per iteration.
if @lint[0] == self