diff options
50 files changed, 825 insertions, 213 deletions
@@ -9,3 +9,4 @@ Gemfile.lock .rbx doc /.bundle +/.yardoc diff --git a/.travis.yml b/.travis.yml index 56ed952d..aa2eca43 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,8 +9,9 @@ rvm: - 1.9.2 - 1.9.3 - 2.0.0 - - 2.1.0-preview2 - - rbx + - 2.1 + - ruby-head + - rbx-2 - jruby - ree notifications: diff --git a/.yardopts b/.yardopts new file mode 100644 index 00000000..f4d6aeba --- /dev/null +++ b/.yardopts @@ -0,0 +1,2 @@ +- +SPEC @@ -3,7 +3,7 @@ source 'https://rubygems.org' gemspec group :extra do - gem 'ruby-fcgi' + gem 'fcgi' gem 'memcache-client' gem 'mongrel', '>= 1.2.0.pre2' gem 'thin' diff --git a/README.rdoc b/README.rdoc index 7a3c8d58..d5bd6d2e 100644 --- a/README.rdoc +++ b/README.rdoc @@ -1,4 +1,4 @@ -= Rack, a modular Ruby webserver interface {<img src="https://secure.travis-ci.org/rack/rack.png" alt="Build Status" />}[http://travis-ci.org/rack/rack] {<img src="https://gemnasium.com/rack/rack.png" alt="Dependency Status" />}[https://gemnasium.com/rack/rack] += Rack, a modular Ruby webserver interface {<img src="https://secure.travis-ci.org/rack/rack.svg" alt="Build Status" />}[http://travis-ci.org/rack/rack] {<img src="https://gemnasium.com/rack/rack.svg" alt="Dependency Status" />}[https://gemnasium.com/rack/rack] Rack provides a minimal, modular and adaptable interface for developing web applications in Ruby. By wrapping HTTP requests and responses in @@ -33,6 +33,7 @@ These web servers include Rack handlers in their distributions: * Unicorn * unixrack * uWSGI +* yahns * Zbatery Any valid Rack app will run the same on all these handlers, without @@ -40,7 +40,17 @@ below. <tt>QUERY_STRING</tt>:: The portion of the request URL that follows the <tt>?</tt>, if any. May be empty, but is always required! -<tt>SERVER_NAME</tt>, <tt>SERVER_PORT</tt>:: When combined with <tt>SCRIPT_NAME</tt> and <tt>PATH_INFO</tt>, these variables can be used to complete the URL. Note, however, that <tt>HTTP_HOST</tt>, if present, should be used in preference to <tt>SERVER_NAME</tt> for reconstructing the request URL. <tt>SERVER_NAME</tt> and <tt>SERVER_PORT</tt> can never be empty strings, and so are always required. +<tt>SERVER_NAME</tt>, <tt>SERVER_PORT</tt>:: + When combined with <tt>SCRIPT_NAME</tt> and + <tt>PATH_INFO</tt>, these variables can be + used to complete the URL. Note, however, + that <tt>HTTP_HOST</tt>, if present, + should be used in preference to + <tt>SERVER_NAME</tt> for reconstructing + the request URL. + <tt>SERVER_NAME</tt> and <tt>SERVER_PORT</tt> + can never be empty strings, and so + are always required. <tt>HTTP_</tt> Variables:: Variables corresponding to the client-supplied HTTP request headers (i.e., variables whose @@ -49,24 +59,47 @@ below. variables should correspond with the presence or absence of the appropriate HTTP header in the - request. See <a href="https://tools.ietf.org/html/rfc3875#section-4.1.18"> - RFC3875 section 4.1.18</a> for specific behavior. + request. See + <a href="https://tools.ietf.org/html/rfc3875#section-4.1.18"> + RFC3875 section 4.1.18</a> for + specific behavior. In addition to this, the Rack environment must include these Rack-specific variables: -<tt>rack.version</tt>:: The Array representing this version of Rack. See Rack::VERSION, that corresponds to the version of this SPEC. -<tt>rack.url_scheme</tt>:: +http+ or +https+, depending on the request URL. +<tt>rack.version</tt>:: The Array representing this version of Rack + See Rack::VERSION, that corresponds to + the version of this SPEC. +<tt>rack.url_scheme</tt>:: +http+ or +https+, depending on the + request URL. <tt>rack.input</tt>:: See below, the input stream. <tt>rack.errors</tt>:: See below, the error stream. -<tt>rack.multithread</tt>:: true if the application object may be simultaneously invoked by another thread in the same process, false otherwise. -<tt>rack.multiprocess</tt>:: true if an equivalent application object may be simultaneously invoked by another process, false otherwise. -<tt>rack.run_once</tt>:: true if the server expects (but does not guarantee!) that the application will only be invoked this one time during the life of its containing process. Normally, this will only be true for a server based on CGI (or something similar). -<tt>rack.hijack?</tt>:: present and true if the server supports connection hijacking. See below, hijacking. -<tt>rack.hijack</tt>:: an object responding to #call that must be called at least once before using rack.hijack_io. It is recommended #call return rack.hijack_io as well as setting it in env if necessary. -<tt>rack.hijack_io</tt>:: if rack.hijack? is true, and rack.hijack has received #call, this will contain an object resembling an IO. See hijacking. +<tt>rack.multithread</tt>:: true if the application object may be + simultaneously invoked by another thread + in the same process, false otherwise. +<tt>rack.multiprocess</tt>:: true if an equivalent application object + may be simultaneously invoked by another + process, false otherwise. +<tt>rack.run_once</tt>:: true if the server expects + (but does not guarantee!) that the + application will only be invoked this one + time during the life of its containing + process. Normally, this will only be true + for a server based on CGI + (or something similar). +<tt>rack.hijack?</tt>:: present and true if the server supports + connection hijacking. See below, hijacking. +<tt>rack.hijack</tt>:: an object responding to #call that must be + called at least once before using + rack.hijack_io. + It is recommended #call return rack.hijack_io + as well as setting it in env if necessary. +<tt>rack.hijack_io</tt>:: if rack.hijack? is true, and rack.hijack + has received #call, this will contain + an object resembling an IO. See hijacking. 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 request session data. +<tt>rack.session</tt>:: A hash like interface for storing + request session data. The store must implement: store(key, value) (aliased as []=); fetch(key, default = nil) (aliased as []); @@ -110,15 +143,18 @@ must be opened in binary mode, for Ruby 1.9 compatibility. The input stream must respond to +gets+, +each+, +read+ and +rewind+. * +gets+ must be called without arguments and return a string, or +nil+ on EOF. -* +read+ behaves like IO#read. 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. If +length+ is given and not nil, then this method - reads at most +length+ bytes from the input stream. If +length+ is not given or nil, - then this method reads all data until EOF. - When EOF is reached, this method returns nil if +length+ is given and not nil, or "" - if +length+ is not given or is nil. - If +buffer+ is given, then the read data will be placed into +buffer+ instead of a - newly created String object. +* +read+ behaves like IO#read. + 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. + If +length+ is given and not nil, then this method reads at most + +length+ bytes from the input stream. + If +length+ is not given or nil, then this method reads + all data until EOF. + When EOF is reached, this method returns nil if +length+ is given + and not nil, or "" if +length+ is not given or is nil. + If +buffer+ is given, then the read data will be placed + into +buffer+ instead of a newly created String object. * +each+ must be called without arguments and only yield Strings. * +rewind+ must be called without arguments. It rewinds the input stream back to the beginning. It must not raise Errno::ESPIPE: diff --git a/lib/rack.rb b/lib/rack.rb index 57119df3..341514c5 100644 --- a/lib/rack.rb +++ b/lib/rack.rb @@ -53,6 +53,7 @@ module Rack autoload :ShowExceptions, "rack/showexceptions" autoload :ShowStatus, "rack/showstatus" autoload :Static, "rack/static" + autoload :TempfileReaper, "rack/tempfile_reaper" autoload :URLMap, "rack/urlmap" autoload :Utils, "rack/utils" autoload :Multipart, "rack/multipart" diff --git a/lib/rack/builder.rb b/lib/rack/builder.rb index fa3a1ea9..bda3be27 100644 --- a/lib/rack/builder.rb +++ b/lib/rack/builder.rb @@ -73,7 +73,7 @@ module Rack # end # # use Middleware - # run lambda { |env| [200, { "Content-Type => "text/plain" }, ["OK"]] } + # run lambda { |env| [200, { "Content-Type" => "text/plain" }, ["OK"]] } # # All requests through to this application will first be processed by the middleware class. # The +call+ method in this example sets an additional environment key which then can be @@ -157,7 +157,7 @@ module Rack def generate_map(default_app, mapping) mapped = default_app ? {'/' => default_app} : {} - mapping.each { |r,b| mapped[r] = self.class.new(default_app, &b) } + mapping.each { |r,b| mapped[r] = self.class.new(default_app, &b).to_app } URLMap.new(mapped) end end diff --git a/lib/rack/chunked.rb b/lib/rack/chunked.rb index a400756a..ea221fa9 100644 --- a/lib/rack/chunked.rb +++ b/lib/rack/chunked.rb @@ -39,11 +39,22 @@ module Rack @app = app end + # pre-HTTP/1.0 (informally "HTTP/0.9") HTTP requests did not have + # a version (nor response headers) + def chunkable_version?(ver) + case ver + when "HTTP/1.0", nil, "HTTP/0.9" + false + else + true + end + end + def call(env) status, headers, body = @app.call(env) headers = HeaderHash.new(headers) - if env['HTTP_VERSION'] == 'HTTP/1.0' || + if ! chunkable_version?(env['HTTP_VERSION']) || STATUS_WITH_NO_ENTITY_BODY.include?(status) || headers['Content-Length'] || headers['Transfer-Encoding'] diff --git a/lib/rack/conditionalget.rb b/lib/rack/conditionalget.rb index ed87c54e..88573166 100644 --- a/lib/rack/conditionalget.rb +++ b/lib/rack/conditionalget.rb @@ -28,7 +28,10 @@ module Rack status = 304 headers.delete('Content-Type') headers.delete('Content-Length') - body = [] + original_body = body + body = Rack::BodyProxy.new([]) do + original_body.close if original_body.respond_to?(:close) + end end [status, headers, body] else diff --git a/lib/rack/content_length.rb b/lib/rack/content_length.rb index 634bdc41..71bc919b 100644 --- a/lib/rack/content_length.rb +++ b/lib/rack/content_length.rb @@ -1,4 +1,5 @@ require 'rack/utils' +require 'rack/body_proxy' module Rack @@ -22,7 +23,10 @@ module Rack obody = body body, length = [], 0 obody.each { |part| body << part; length += bytesize(part) } - obody.close if obody.respond_to?(:close) + + body = BodyProxy.new(body) do + obody.close if obody.respond_to?(:close) + end headers['Content-Length'] = length.to_s end diff --git a/lib/rack/deflater.rb b/lib/rack/deflater.rb index 638bf049..9df510bd 100644 --- a/lib/rack/deflater.rb +++ b/lib/rack/deflater.rb @@ -65,9 +65,9 @@ module Rack when "identity" [status, headers, body] when nil - body.close if body.respond_to?(:close) message = "An acceptable encoding for the requested resource #{request.fullpath} could not be found." - [406, {"Content-Type" => "text/plain", "Content-Length" => message.length.to_s}, [message]] + bp = Rack::BodyProxy.new([message]) { body.close if body.respond_to?(:close) } + [406, {"Content-Type" => "text/plain", "Content-Length" => message.length.to_s}, bp] end end @@ -87,7 +87,6 @@ module Rack gzip.flush } ensure - close gzip.close @writer = nil end @@ -123,7 +122,6 @@ module Rack yield deflator.finish nil ensure - close deflator.close end diff --git a/lib/rack/etag.rb b/lib/rack/etag.rb index 5fa09abd..fefe671f 100644 --- a/lib/rack/etag.rb +++ b/lib/rack/etag.rb @@ -23,8 +23,12 @@ module Rack status, headers, body = @app.call(env) if etag_status?(status) && etag_body?(body) && !skip_caching?(headers) - digest, body = digest_body(body) - headers['ETag'] = %("#{digest}") if digest + original_body = body + digest, new_body = digest_body(body) + body = Rack::BodyProxy.new(new_body) do + original_body.close if original_body.respond_to?(:close) + end + headers['ETag'] = %(W/"#{digest}") if digest end unless headers['Cache-Control'] diff --git a/lib/rack/file.rb b/lib/rack/file.rb index 820ecd60..c8f8d0d1 100644 --- a/lib/rack/file.rb +++ b/lib/rack/file.rb @@ -12,7 +12,6 @@ module Rack # like sendfile on the +path+. class File - SEPS = Regexp.union(*[::File::SEPARATOR, ::File::ALT_SEPARATOR].compact) ALLOWED_VERBS = %w[GET HEAD OPTIONS] ALLOW_HEADER = ALLOWED_VERBS.join(', ') @@ -40,16 +39,9 @@ module Rack end path_info = Utils.unescape(env["PATH_INFO"]) - parts = path_info.split SEPS + clean_path_info = Utils.clean_path_info(path_info) - clean = [] - - parts.each do |part| - next if part.empty? || part == '.' - part == '..' ? clean.pop : clean << part - end - - @path = F.join(@root, *clean) + @path = F.join(@root, clean_path_info) available = begin F.file?(@path) && F.readable?(@path) diff --git a/lib/rack/handler.rb b/lib/rack/handler.rb index 8f649974..b5fa0268 100644 --- a/lib/rack/handler.rb +++ b/lib/rack/handler.rb @@ -53,9 +53,9 @@ module Rack Rack::Handler::FastCGI elsif ENV.include?("REQUEST_METHOD") Rack::Handler::CGI - elsif ENV.include?("RACK_HANDLER") + elsif ENV.include?("RACK_HANDLER") self.get(ENV["RACK_HANDLER"]) - else + else pick ['thin', 'puma', 'webrick'] end end diff --git a/lib/rack/handler/webrick.rb b/lib/rack/handler/webrick.rb index f76679b4..023d8b27 100644 --- a/lib/rack/handler/webrick.rb +++ b/lib/rack/handler/webrick.rb @@ -2,6 +2,23 @@ require 'webrick' require 'stringio' require 'rack/content_length' +# This monkey patch allows for applications to perform their own chunking +# through WEBrick::HTTPResponse iff rack is set to true. +class WEBrick::HTTPResponse + attr_accessor :rack + + alias _rack_setup_header setup_header + def setup_header + app_chunking = rack && @header['transfer-encoding'] == 'chunked' + + @chunked = app_chunking if app_chunking + + _rack_setup_header + + @chunked = false if app_chunking + end +end + module Rack module Handler class WEBrick < ::WEBrick::HTTPServlet::AbstractServlet @@ -39,6 +56,7 @@ module Rack end def service(req, res) + res.rack = true env = req.meta_vars env.delete_if { |k, v| v.nil? } diff --git a/lib/rack/head.rb b/lib/rack/head.rb index 7ffead6c..72f3dbdd 100644 --- a/lib/rack/head.rb +++ b/lib/rack/head.rb @@ -1,3 +1,5 @@ +require 'rack/body_proxy' + module Rack class Head @@ -11,8 +13,11 @@ class Head status, headers, body = @app.call(env) if env["REQUEST_METHOD"] == "HEAD" - body.close if body.respond_to? :close - [status, headers, []] + [ + status, headers, Rack::BodyProxy.new([]) do + body.close if body.respond_to? :close + end + ] else [status, headers, body] end diff --git a/lib/rack/lint.rb b/lib/rack/lint.rb index 3978b70a..667c34a6 100644 --- a/lib/rack/lint.rb +++ b/lib/rack/lint.rb @@ -102,7 +102,17 @@ module Rack ## follows the <tt>?</tt>, if any. May be ## empty, but is always required! - ## <tt>SERVER_NAME</tt>, <tt>SERVER_PORT</tt>:: When combined with <tt>SCRIPT_NAME</tt> and <tt>PATH_INFO</tt>, these variables can be used to complete the URL. Note, however, that <tt>HTTP_HOST</tt>, if present, should be used in preference to <tt>SERVER_NAME</tt> for reconstructing the request URL. <tt>SERVER_NAME</tt> and <tt>SERVER_PORT</tt> can never be empty strings, and so are always required. + ## <tt>SERVER_NAME</tt>, <tt>SERVER_PORT</tt>:: + ## When combined with <tt>SCRIPT_NAME</tt> and + ## <tt>PATH_INFO</tt>, these variables can be + ## used to complete the URL. Note, however, + ## that <tt>HTTP_HOST</tt>, if present, + ## should be used in preference to + ## <tt>SERVER_NAME</tt> for reconstructing + ## the request URL. + ## <tt>SERVER_NAME</tt> and <tt>SERVER_PORT</tt> + ## can never be empty strings, and so + ## are always required. ## <tt>HTTP_</tt> Variables:: Variables corresponding to the ## client-supplied HTTP request @@ -112,29 +122,60 @@ module Rack ## variables should correspond with ## the presence or absence of the ## appropriate HTTP header in the - ## request. See <a href="https://tools.ietf.org/html/rfc3875#section-4.1.18"> - ## RFC3875 section 4.1.18</a> for specific behavior. + ## request. See + ## <a href="https://tools.ietf.org/html/rfc3875#section-4.1.18"> + ## RFC3875 section 4.1.18</a> for + ## specific behavior. ## In addition to this, the Rack environment must include these ## Rack-specific variables: - ## <tt>rack.version</tt>:: The Array representing this version of Rack. See Rack::VERSION, that corresponds to the version of this SPEC. - ## <tt>rack.url_scheme</tt>:: +http+ or +https+, depending on the request URL. + ## <tt>rack.version</tt>:: The Array representing this version of Rack + ## See Rack::VERSION, that corresponds to + ## the version of this SPEC. + + ## <tt>rack.url_scheme</tt>:: +http+ or +https+, depending on the + ## request URL. + ## <tt>rack.input</tt>:: See below, the input stream. + ## <tt>rack.errors</tt>:: See below, the error stream. - ## <tt>rack.multithread</tt>:: true if the application object may be simultaneously invoked by another thread in the same process, false otherwise. - ## <tt>rack.multiprocess</tt>:: true if an equivalent application object may be simultaneously invoked by another process, false otherwise. - ## <tt>rack.run_once</tt>:: true if the server expects (but does not guarantee!) that the application will only be invoked this one time during the life of its containing process. Normally, this will only be true for a server based on CGI (or something similar). - ## <tt>rack.hijack?</tt>:: present and true if the server supports connection hijacking. See below, hijacking. - ## <tt>rack.hijack</tt>:: an object responding to #call that must be called at least once before using rack.hijack_io. It is recommended #call return rack.hijack_io as well as setting it in env if necessary. - ## <tt>rack.hijack_io</tt>:: if rack.hijack? is true, and rack.hijack has received #call, this will contain an object resembling an IO. See hijacking. - ## + + ## <tt>rack.multithread</tt>:: true if the application object may be + ## simultaneously invoked by another thread + ## in the same process, false otherwise. + + ## <tt>rack.multiprocess</tt>:: true if an equivalent application object + ## may be simultaneously invoked by another + ## process, false otherwise. + + ## <tt>rack.run_once</tt>:: true if the server expects + ## (but does not guarantee!) that the + ## application will only be invoked this one + ## time during the life of its containing + ## process. Normally, this will only be true + ## for a server based on CGI + ## (or something similar). + + ## <tt>rack.hijack?</tt>:: present and true if the server supports + ## connection hijacking. See below, hijacking. + + ## <tt>rack.hijack</tt>:: an object responding to #call that must be + ## called at least once before using + ## rack.hijack_io. + ## It is recommended #call return rack.hijack_io + ## as well as setting it in env if necessary. + + ## <tt>rack.hijack_io</tt>:: if rack.hijack? is true, and rack.hijack + ## has received #call, this will contain + ## an object resembling an IO. See hijacking. ## 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 request session data. + ## <tt>rack.session</tt>:: A hash like interface for storing + ## request session data. ## The store must implement: if session = env['rack.session'] ## store(key, value) (aliased as []=); @@ -218,7 +259,6 @@ module Rack } } - ## ## There are the following restrictions: ## * <tt>rack.version</tt> must be an array of Integers. @@ -311,15 +351,23 @@ module Rack v end - ## * +read+ behaves like IO#read. 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. If +length+ is given and not nil, then this method - ## reads at most +length+ bytes from the input stream. If +length+ is not given or nil, - ## then this method reads all data until EOF. - ## When EOF is reached, this method returns nil if +length+ is given and not nil, or "" - ## if +length+ is not given or is nil. - ## If +buffer+ is given, then the read data will be placed into +buffer+ instead of a - ## newly created String object. + ## * +read+ behaves like IO#read. + ## 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. + ## + ## If +length+ is given and not nil, then this method reads at most + ## +length+ bytes from the input stream. + ## + ## If +length+ is not given or nil, then this method reads + ## all data until EOF. + ## + ## When EOF is reached, this method returns nil if +length+ is given + ## and not nil, or "" if +length+ is not given or is nil. + ## + ## If +buffer+ is given, then the read data will be placed + ## into +buffer+ instead of a newly created String object. def read(*args) assert("rack.input#read called with too many arguments") { args.size <= 2 diff --git a/lib/rack/lobster.rb b/lib/rack/lobster.rb index d1a7f7bc..195bd945 100644 --- a/lib/rack/lobster.rb +++ b/lib/rack/lobster.rb @@ -32,9 +32,14 @@ module Rack def call(env) req = Request.new(env) if req.GET["flip"] == "left" - lobster = LobsterString.split("\n"). - map { |line| line.ljust(42).reverse }. - join("\n") + lobster = LobsterString.split("\n").map do |line| + line.ljust(42).reverse. + gsub('\\', 'TEMP'). + gsub('/', '\\'). + gsub('TEMP', '/'). + gsub('{','}'). + gsub('(',')') + end.join("\n") href = "?flip=right" elsif req.GET["flip"] == "crash" raise "Lobster crashed" diff --git a/lib/rack/methodoverride.rb b/lib/rack/methodoverride.rb index 449961ce..062f3d67 100644 --- a/lib/rack/methodoverride.rb +++ b/lib/rack/methodoverride.rb @@ -4,13 +4,14 @@ module Rack METHOD_OVERRIDE_PARAM_KEY = "_method".freeze HTTP_METHOD_OVERRIDE_HEADER = "HTTP_X_HTTP_METHOD_OVERRIDE".freeze + ALLOWED_METHODS = ["POST"] def initialize(app) @app = app end def call(env) - if env["REQUEST_METHOD"] == "POST" + if allowed_methods.include?(env["REQUEST_METHOD"]) method = method_override(env) if HTTP_METHODS.include?(method) env["rack.methodoverride.original_method"] = env["REQUEST_METHOD"] @@ -23,9 +24,19 @@ module Rack def method_override(env) req = Request.new(env) - method = req.POST[METHOD_OVERRIDE_PARAM_KEY] || + method = method_override_param(req) || env[HTTP_METHOD_OVERRIDE_HEADER] method.to_s.upcase end + + private + + def allowed_methods + ALLOWED_METHODS + end + + def method_override_param(req) + req.POST[METHOD_OVERRIDE_PARAM_KEY] + end end end diff --git a/lib/rack/mock.rb b/lib/rack/mock.rb index 3ba314e4..3c02c1fe 100644 --- a/lib/rack/mock.rb +++ b/lib/rack/mock.rb @@ -77,9 +77,16 @@ module Rack body.close if body.respond_to?(:close) end + # For historical reasons, we're pinning to RFC 2396. It's easier for users + # and we get support from ruby 1.8 to 2.2 using this method. + def self.parse_uri_rfc2396(uri) + @parser ||= defined?(URI::RFC2396_Parser) ? URI::RFC2396_Parser.new : URI + @parser.parse(uri) + end + # Return the Rack environment used for a request to +uri+. def self.env_for(uri="", opts={}) - uri = URI(uri) + uri = parse_uri_rfc2396(uri) uri.path = "/#{uri.path}" unless uri.path[0] == ?/ env = DEFAULT_ENV.dup diff --git a/lib/rack/multipart.rb b/lib/rack/multipart.rb index d67ff051..7a44c4d4 100644 --- a/lib/rack/multipart.rb +++ b/lib/rack/multipart.rb @@ -9,7 +9,7 @@ module Rack EOL = "\r\n" MULTIPART_BOUNDARY = "AaB03x" - MULTIPART = %r|\Amultipart/.*boundary=\"?([^\";,]+)\"?|n + MULTIPART = %r|\Amultipart/.*boundary=\"?([^\";,]+)\"?|ni TOKEN = /[^\s()<>,;:\\"\/\[\]?=]+/ CONDISP = /Content-Disposition:\s*#{TOKEN}\s*/i DISPPARM = /;\s*(#{TOKEN})=("(?:\\"|[^"])*"|#{TOKEN})/ diff --git a/lib/rack/multipart/parser.rb b/lib/rack/multipart/parser.rb index fa47fd16..22f9734b 100644 --- a/lib/rack/multipart/parser.rb +++ b/lib/rack/multipart/parser.rb @@ -16,10 +16,10 @@ module Rack content_length = env['CONTENT_LENGTH'] content_length = content_length.to_i if content_length - new($1, io, content_length) + new($1, io, content_length, env) end - def initialize(boundary, io, content_length) + def initialize(boundary, io, content_length, env) @buf = "" if @buf.respond_to? :force_encoding @@ -31,6 +31,7 @@ module Rack @io = io @content_length = content_length @boundary_size = Utils.bytesize(@boundary) + EOL.size + @env = env if @content_length @content_length -= @boundary_size @@ -111,8 +112,12 @@ module Rack filename = get_filename(head) + if name.nil? || name.empty? && filename + name = filename + end + if filename - body = Tempfile.new("RackMultipart") + (@env['rack.tempfiles'] ||= []) << body = Tempfile.new("RackMultipart") body.binmode if body.respond_to?(:binmode) end diff --git a/lib/rack/multipart/uploaded_file.rb b/lib/rack/multipart/uploaded_file.rb index 11932b17..1b56ad75 100644 --- a/lib/rack/multipart/uploaded_file.rb +++ b/lib/rack/multipart/uploaded_file.rb @@ -11,7 +11,7 @@ module Rack raise "#{path} file does not exist" unless ::File.exist?(path) @content_type = content_type @original_filename = ::File.basename(path) - @tempfile = Tempfile.new(@original_filename) + @tempfile = Tempfile.new([@original_filename, ::File.extname(path)]) @tempfile.set_encoding(Encoding::BINARY) if @tempfile.respond_to?(:set_encoding) @tempfile.binmode if binary FileUtils.copy_file(path, @tempfile.path) @@ -31,4 +31,4 @@ module Rack end end end -end
\ No newline at end of file +end diff --git a/lib/rack/request.rb b/lib/rack/request.rb index 80d5e0db..52ea652c 100644 --- a/lib/rack/request.rb +++ b/lib/rack/request.rb @@ -8,10 +8,6 @@ module Rack # req = Rack::Request.new(env) # req.post? # req.params["data"] - # - # The environment hash passed will store a reference to the Request object - # instantiated so that it will only instantiate if an instance of the Request - # object doesn't already exist. class Request # The environment of the request. @@ -56,7 +52,7 @@ module Rack return {} if content_type.nil? Hash[*content_type.split(/\s*[;,]\s*/)[1..-1]. collect { |s| s.split('=', 2) }. - map { |k,v| [k.downcase, v] }.flatten] + map { |k,v| [k.downcase, strip_doublequotes(v)] }.flatten] end # The character set of the request body if a "charset" media type @@ -142,7 +138,7 @@ module Rack # Checks the HTTP request method (or verb) to see if it was of type TRACE def trace?; request_method == "TRACE" end - + # Checks the HTTP request method (or verb) to see if it was of type UNLINK def unlink?; request_method == "UNLINK" end @@ -192,8 +188,9 @@ module Rack if @env["rack.request.query_string"] == query_string @env["rack.request.query_hash"] else + p = parse_query(query_string) @env["rack.request.query_string"] = query_string - @env["rack.request.query_hash"] = parse_query(query_string) + @env["rack.request.query_hash"] = p end end @@ -337,14 +334,11 @@ module Rack end def accept_encoding - @env["HTTP_ACCEPT_ENCODING"].to_s.split(/\s*,\s*/).map do |part| - encoding, parameters = part.split(/\s*;\s*/, 2) - quality = 1.0 - if parameters and /\Aq=([\d.]+)/ =~ parameters - quality = $1.to_f - end - [encoding, quality] - end + parse_http_accept_header(@env["HTTP_ACCEPT_ENCODING"]) + end + + def accept_language + parse_http_accept_header(@env["HTTP_ACCEPT_LANGUAGE"]) end def trusted_proxy?(ip) @@ -359,12 +353,6 @@ module Rack forwarded_ips = split_ip_addresses(@env['HTTP_X_FORWARDED_FOR']) - if client_ip = @env['HTTP_CLIENT_IP'] - # If forwarded_ips doesn't include the client_ip, it might be an - # ip spoofing attempt, so we ignore HTTP_CLIENT_IP - return client_ip if forwarded_ips.include?(client_ip) - end - return reject_trusted_ip_addresses(forwarded_ips).last || @env["REMOTE_ADDR"] end @@ -378,11 +366,31 @@ module Rack end def parse_query(qs) - Utils.parse_nested_query(qs) + Utils.parse_nested_query(qs, '&') end def parse_multipart(env) Rack::Multipart.parse_multipart(env) end + + def parse_http_accept_header(header) + header.to_s.split(/\s*,\s*/).map do |part| + attribute, parameters = part.split(/\s*;\s*/, 2) + quality = 1.0 + if parameters and /\Aq=([\d.]+)/ =~ parameters + quality = $1.to_f + end + [attribute, quality] + end + end + + private + def strip_doublequotes(s) + if s[0] == ?" && s[-1] == ?" + s[1..-2] + else + s + end + end end end diff --git a/lib/rack/response.rb b/lib/rack/response.rb index 2076aff0..12536710 100644 --- a/lib/rack/response.rb +++ b/lib/rack/response.rb @@ -1,5 +1,6 @@ require 'rack/request' require 'rack/utils' +require 'rack/body_proxy' require 'time' module Rack @@ -121,11 +122,14 @@ module Rack def server_error?; status >= 500 && status < 600; end def ok?; status == 200; end + def created?; status == 201; end + def accepted?; status == 202; end def bad_request?; status == 400; end def unauthorized?; status == 401; end def forbidden?; status == 403; end def not_found?; status == 404; end def method_not_allowed?; status == 405; end + def i_m_a_teapot?; status == 418; end def unprocessable?; status == 422; end def redirect?; [301, 302, 303, 307].include? status; end diff --git a/lib/rack/sendfile.rb b/lib/rack/sendfile.rb index 8a674904..8e2c3d67 100644 --- a/lib/rack/sendfile.rb +++ b/lib/rack/sendfile.rb @@ -1,4 +1,5 @@ require 'rack/file' +require 'rack/body_proxy' module Rack @@ -117,8 +118,10 @@ module Rack if url = map_accel_path(env, path) headers['Content-Length'] = '0' headers[type] = url - body.close if body.respond_to?(:close) - body = [] + obody = body + body = Rack::BodyProxy.new([]) do + obody.close if obody.respond_to?(:close) + end else env['rack.errors'].puts "X-Accel-Mapping header missing" end @@ -126,8 +129,10 @@ module Rack path = F.expand_path(body.to_path) headers['Content-Length'] = '0' headers[type] = path - body.close if body.respond_to?(:close) - body = [] + obody = body + body = Rack::BodyProxy.new([]) do + obody.close if obody.respond_to?(:close) + end when '', nil else env['rack.errors'].puts "Unknown x-sendfile variation: '#{type}'.\n" diff --git a/lib/rack/server.rb b/lib/rack/server.rb index be7014c6..d2f0b954 100644 --- a/lib/rack/server.rb +++ b/lib/rack/server.rb @@ -1,7 +1,10 @@ require 'optparse' + module Rack + class Server + class Options def parse!(args) options = {} @@ -166,7 +169,7 @@ module Rack # * :Port # the port to bind to (used by supporting Rack::Handler) # * :AccessLog - # webrick acess log options (or supporting Rack::Handler) + # webrick access log options (or supporting Rack::Handler) # * :debug # turn on debug output ($DEBUG = true) # * :warn @@ -202,29 +205,44 @@ module Rack @app ||= options[:builder] ? build_app_from_string : build_app_and_options_from_config end - def self.logging_middleware - lambda { |server| - server.server.name =~ /CGI/ ? nil : [Rack::CommonLogger, $stderr] - } - end + class << self + def logging_middleware + lambda { |server| + server.server.name =~ /CGI/ ? nil : [Rack::CommonLogger, $stderr] + } + end - def self.middleware - @middleware ||= begin + def default_middleware_by_environment m = Hash.new {|h,k| h[k] = []} - m["deployment"].concat [ + m["deployment"] = [ [Rack::ContentLength], [Rack::Chunked], - logging_middleware + logging_middleware, + [Rack::TempfileReaper] ] - m["development"].concat m["deployment"] + [[Rack::ShowExceptions], [Rack::Lint]] + m["development"] = [ + [Rack::ContentLength], + [Rack::Chunked], + logging_middleware, + [Rack::ShowExceptions], + [Rack::Lint], + [Rack::TempfileReaper] + ] + m end + + # Aliased for backwards-compatibility + alias :middleware :default_middleware_by_environment end - def middleware - self.class.middleware + def default_middleware_by_environment + self.class.default_middleware_by_environment end + # Aliased for backwards-compatibility + alias :middleware :default_middleware_by_environment + def start &blk if options[:warn] $-w = true @@ -304,7 +322,8 @@ module Rack end def build_app(app) - middleware[options[:environment]].reverse_each do |middleware| + middlewares = default_middleware_by_environment[options[:environment]] + middlewares.reverse_each do |middleware| middleware = middleware.call(self) if middleware.respond_to?(:call) next unless middleware klass, *args = middleware @@ -364,4 +383,5 @@ module Rack end end + end diff --git a/lib/rack/session/memcache.rb b/lib/rack/session/memcache.rb index 53261fda..c0e1f3ec 100644 --- a/lib/rack/session/memcache.rb +++ b/lib/rack/session/memcache.rb @@ -47,7 +47,7 @@ module Rack end def get_session(env, sid) - with_lock(env, [nil, {}]) do + with_lock(env) do unless sid and session = @pool.get(sid) sid, session = generate_sid, {} unless /^STORED/ =~ @pool.add(sid, session) @@ -62,7 +62,7 @@ module Rack expiry = options[:expire_after] expiry = expiry.nil? ? 0 : expiry + 1 - with_lock(env, false) do + with_lock(env) do @pool.set session_id, new_session, expiry session_id end @@ -75,7 +75,7 @@ module Rack end end - def with_lock(env, default=nil) + def with_lock(env) @mutex.lock if env['rack.multithread'] yield rescue MemCache::MemCacheError, Errno::ECONNREFUSED @@ -83,7 +83,7 @@ module Rack warn "#{self} is unable to find memcached server." warn $!.inspect end - default + raise ensure @mutex.unlock if @mutex.locked? end diff --git a/lib/rack/session/pool.rb b/lib/rack/session/pool.rb index d4774fed..fcb34ec4 100644 --- a/lib/rack/session/pool.rb +++ b/lib/rack/session/pool.rb @@ -42,7 +42,7 @@ module Rack end def get_session(env, sid) - with_lock(env, [nil, {}]) do + with_lock(env) do unless sid and session = @pool[sid] sid, session = generate_sid, {} @pool.store sid, session @@ -52,7 +52,7 @@ module Rack end def set_session(env, session_id, new_session, options) - with_lock(env, false) do + with_lock(env) do @pool.store session_id, new_session session_id end @@ -65,15 +65,12 @@ module Rack end end - def with_lock(env, default=nil) + def with_lock(env) @mutex.lock if env['rack.multithread'] yield - rescue - default ensure @mutex.unlock if @mutex.locked? end - end end end diff --git a/lib/rack/showexceptions.rb b/lib/rack/showexceptions.rb index c91ca07c..731aea49 100644 --- a/lib/rack/showexceptions.rb +++ b/lib/rack/showexceptions.rb @@ -28,23 +28,32 @@ module Rack env["rack.errors"].puts(exception_string) env["rack.errors"].flush - if prefers_plain_text?(env) - content_type = "text/plain" - body = [exception_string] - else + if accepts_html?(env) content_type = "text/html" body = pretty(env, e) + else + content_type = "text/plain" + body = exception_string end - [500, - {"Content-Type" => content_type, - "Content-Length" => Rack::Utils.bytesize(body.join).to_s}, - body] + [ + 500, + { + "Content-Type" => content_type, + "Content-Length" => Rack::Utils.bytesize(body).to_s, + }, + [body], + ] + end + + def prefers_plaintext?(env) + !accepts_html(env) end - def prefers_plain_text?(env) - env["HTTP_X_REQUESTED_WITH"] == "XMLHttpRequest" && (!env["HTTP_ACCEPT"] || !env["HTTP_ACCEPT"].include?("text/html")) + def accepts_html?(env) + Rack::Utils.best_q_match(env["HTTP_ACCEPT"], %w[text/html]) end + private :accepts_html? def dump_exception(exception) string = "#{exception.class}: #{exception.message}\n" @@ -85,7 +94,7 @@ module Rack end }.compact - [@template.result(binding)] + @template.result(binding) end def h(obj) # :nodoc: diff --git a/lib/rack/tempfile_reaper.rb b/lib/rack/tempfile_reaper.rb new file mode 100644 index 00000000..1500b06a --- /dev/null +++ b/lib/rack/tempfile_reaper.rb @@ -0,0 +1,22 @@ +require 'rack/body_proxy' + +module Rack + + # Middleware tracks and cleans Tempfiles created throughout a request (i.e. Rack::Multipart) + # Ideas/strategy based on posts by Eric Wong and Charles Oliver Nutter + # https://groups.google.com/forum/#!searchin/rack-devel/temp/rack-devel/brK8eh-MByw/sw61oJJCGRMJ + class TempfileReaper + def initialize(app) + @app = app + end + + def call(env) + env['rack.tempfiles'] ||= [] + status, headers, body = @app.call(env) + body_proxy = BodyProxy.new(body) do + env['rack.tempfiles'].each { |f| f.close! } unless env['rack.tempfiles'].nil? + end + [status, headers, body_proxy] + end + end +end diff --git a/lib/rack/urlmap.rb b/lib/rack/urlmap.rb index d301ce9b..df9e7d6d 100644 --- a/lib/rack/urlmap.rb +++ b/lib/rack/urlmap.rb @@ -48,9 +48,10 @@ module Rack sPort = env['SERVER_PORT'] @mapping.each do |host, location, match, app| - unless hHost == host \ - || sName == host \ - || (!host && (hHost == sName || hHost == sName+':'+sPort)) + unless casecmp?(hHost, host) \ + || casecmp?(sName, host) \ + || (!host && (casecmp?(hHost, sName) || + casecmp?(hHost, sName+':'+sPort))) next end @@ -71,6 +72,19 @@ module Rack env['PATH_INFO'] = path env['SCRIPT_NAME'] = script_name end + + private + def casecmp?(v1, v2) + # if both nil, or they're the same string + return true if v1 == v2 + + # if either are nil... (but they're not the same) + return false if v1.nil? + return false if v2.nil? + + # otherwise check they're not case-insensitive the same + v1.casecmp(v2).zero? + end end end diff --git a/lib/rack/utils.rb b/lib/rack/utils.rb index fa3c0df8..53303995 100644 --- a/lib/rack/utils.rb +++ b/lib/rack/utils.rb @@ -22,6 +22,15 @@ module Rack # applications adopted from all kinds of Ruby libraries. module Utils + # ParameterTypeError is the error that is raised when incoming structural + # parameters (parsed by parse_nested_query) contain conflicting types. + class ParameterTypeError < TypeError; end + + # InvalidParameterError is the error that is raised when incoming structural + # parameters (parsed by parse_nested_query) contain invalid format or byte + # sequence. + class InvalidParameterError < ArgumentError; end + # URI escapes. (CGI style space to +) def escape(s) URI.encode_www_form_component(s) @@ -87,6 +96,11 @@ module Rack end module_function :parse_query + # parse_nested_query expands a query string into structural types. Supported + # types are Arrays, Hashes and basic value types. It is possible to supply + # query strings with parameters of conflicting types, in this case a + # ParameterTypeError is raised. Users are encouraged to return a 400 in this + # case. def parse_nested_query(qs, d = nil) params = KeySpaceConstrainedParams.new @@ -97,9 +111,14 @@ module Rack end return params.to_params_hash + rescue ArgumentError => e + raise InvalidParameterError, e.message end module_function :parse_nested_query + # normalize_params recursively expands parameters into structural types. If + # the structural types represented by two different parameter names are in + # conflict, a ParameterTypeError is raised. def normalize_params(params, name, v = nil) name =~ %r(\A[\[\]]*([^\[\]]+)\]*) k = $1 || '' @@ -109,14 +128,16 @@ module Rack if after == "" params[k] = v + elsif after == "[" + params[name] = v elsif after == "[]" params[k] ||= [] - raise TypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) + raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) params[k] << v elsif after =~ %r(^\[\]\[([^\[\]]+)\]$) || after =~ %r(^\[\](.+)$) child_key = $1 params[k] ||= [] - raise TypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) + raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array) if params_hash_type?(params[k].last) && !params[k].last.key?(child_key) normalize_params(params[k].last, child_key, v) else @@ -124,7 +145,7 @@ module Rack end else params[k] ||= params.class.new - raise TypeError, "expected Hash (got #{params[k].class.name}) for param `#{k}'" unless params_hash_type?(params[k]) + raise ParameterTypeError, "expected Hash (got #{params[k].class.name}) for param `#{k}'" unless params_hash_type?(params[k]) params[k] = normalize_params(params[k], after, v) end @@ -182,13 +203,14 @@ module Rack def best_q_match(q_value_header, available_mimes) values = q_values(q_value_header) - values.map do |req_mime, quality| - match = available_mimes.first { |am| Rack::Mime.match?(am, req_mime) } + matches = values.map do |req_mime, quality| + match = available_mimes.find { |am| Rack::Mime.match?(am, req_mime) } next unless match [match, quality] end.compact.sort_by do |match, quality| (match.split('/', 2).count('*') * -10) + quality - end.last.first + end.last + matches && matches.first end module_function :best_q_match @@ -530,7 +552,11 @@ module Rack hash.keys.each do |key| value = hash[key] if value.kind_of?(self.class) - hash[key] = value.to_params_hash + if value.object_id == self.object_id + hash[key] = hash + else + hash[key] = value.to_params_hash + end elsif value.kind_of?(Array) value.map! {|x| x.kind_of?(self.class) ? x.to_params_hash : x} end @@ -585,6 +611,7 @@ module Rack 415 => 'Unsupported Media Type', 416 => 'Requested Range Not Satisfiable', 417 => 'Expectation Failed', + 418 => 'I\'m a teapot', 422 => 'Unprocessable Entity', 423 => 'Locked', 424 => 'Failed Dependency', @@ -609,7 +636,7 @@ module Rack STATUS_WITH_NO_ENTITY_BODY = Set.new((100..199).to_a << 204 << 205 << 304) SYMBOL_TO_STATUS_CODE = Hash[*HTTP_STATUS_CODES.map { |code, message| - [message.downcase.gsub(/\s|-/, '_').to_sym, code] + [message.downcase.gsub(/\s|-|'/, '_').to_sym, code] }.flatten] def status_code(status) @@ -623,5 +650,23 @@ module Rack Multipart = Rack::Multipart + PATH_SEPS = Regexp.union(*[::File::SEPARATOR, ::File::ALT_SEPARATOR].compact) + + def clean_path_info(path_info) + parts = path_info.split PATH_SEPS + + clean = [] + + parts.each do |part| + next if part.empty? || part == '.' + part == '..' ? clean.pop : clean << part + end + + clean.unshift '/' if parts.empty? || parts.first.empty? + + ::File.join(*clean) + end + module_function :clean_path_info + end end diff --git a/test/multipart/filename_and_no_name b/test/multipart/filename_and_no_name new file mode 100644 index 00000000..00d58153 --- /dev/null +++ b/test/multipart/filename_and_no_name @@ -0,0 +1,6 @@ +--AaB03x
+Content-Disposition: form-data; filename="file1.txt"
+Content-Type: text/plain
+
+contents
+--AaB03x--
diff --git a/test/spec_chunked.rb b/test/spec_chunked.rb index 12f21581..0a6d9ff1 100644 --- a/test/spec_chunked.rb +++ b/test/spec_chunked.rb @@ -64,6 +64,22 @@ describe Rack::Chunked do body.join.should.equal 'Hello World!' end + should 'not modify response when client is ancient, pre-HTTP/1.0' do + app = lambda { |env| [200, {"Content-Type" => "text/plain"}, ['Hello', ' ', 'World!']] } + check = lambda do + status, headers, body = chunked(app).call(@env.dup) + status.should.equal 200 + headers.should.not.include 'Transfer-Encoding' + body.join.should.equal 'Hello World!' + end + + @env.delete('HTTP_VERSION') # unicorn will do this on pre-HTTP/1.0 requests + check.call + + @env['HTTP_VERSION'] = 'HTTP/0.9' # not sure if this happens in practice + check.call + end + should 'not modify response when Transfer-Encoding header already present' do app = lambda { |env| [200, {"Content-Type" => "text/plain", 'Transfer-Encoding' => 'identity'}, ['Hello', ' ', 'World!']] diff --git a/test/spec_content_length.rb b/test/spec_content_length.rb index 4b80a0f4..12c047fb 100644 --- a/test/spec_content_length.rb +++ b/test/spec_content_length.rb @@ -62,7 +62,9 @@ describe Rack::ContentLength do end.new(%w[one two three]) app = lambda { |env| [200, {'Content-Type' => 'text/plain'}, body] } - content_length(app).call(request) + response = content_length(app).call(request) + body.closed.should.equal nil + response[2].close body.closed.should.equal true end diff --git a/test/spec_etag.rb b/test/spec_etag.rb index d7f03504..c075d9d0 100644 --- a/test/spec_etag.rb +++ b/test/spec_etag.rb @@ -21,13 +21,13 @@ describe Rack::ETag do should "set ETag if none is set if status is 200" do app = lambda { |env| [200, {'Content-Type' => 'text/plain'}, ["Hello, World!"]] } response = etag(app).call(request) - response[1]['ETag'].should.equal "\"65a8e27d8879283831b664bd8b7f0ad4\"" + response[1]['ETag'].should.equal "W/\"65a8e27d8879283831b664bd8b7f0ad4\"" end should "set ETag if none is set if status is 201" do app = lambda { |env| [201, {'Content-Type' => 'text/plain'}, ["Hello, World!"]] } response = etag(app).call(request) - response[1]['ETag'].should.equal "\"65a8e27d8879283831b664bd8b7f0ad4\"" + response[1]['ETag'].should.equal "W/\"65a8e27d8879283831b664bd8b7f0ad4\"" end should "set Cache-Control to 'max-age=0, private, must-revalidate' (default) if none is set" do @@ -95,4 +95,13 @@ describe Rack::ETag do response = etag(app).call(request) response[1]['ETag'].should.be.nil end + + should "close the original body" do + body = StringIO.new + app = lambda { |env| [200, {}, body] } + response = etag(app).call(request) + body.should.not.be.closed + response[2].close + body.should.be.closed + end end diff --git a/test/spec_head.rb b/test/spec_head.rb index 18f9a76a..78bc6ad7 100644 --- a/test/spec_head.rb +++ b/test/spec_head.rb @@ -38,6 +38,8 @@ describe Rack::Head do resp[0].should.equal(200) resp[1].should.equal({"Content-type" => "test/plain", "Content-length" => "3"}) resp[2].to_enum.to_a.should.equal([]) + body.should.not.be.closed + resp[2].close body.should.be.closed end end diff --git a/test/spec_lobster.rb b/test/spec_lobster.rb index 56a54795..c6ec2b06 100644 --- a/test/spec_lobster.rb +++ b/test/spec_lobster.rb @@ -47,7 +47,7 @@ describe Rack::Lobster do should "be flippable" do res = lobster.get("/?flip=left") res.should.be.ok - res.body.should.include "(,,,(,,(,(" + res.body.should.include "),,,),,),)" end should "provide crashing for testing purposes" do diff --git a/test/spec_mock.rb b/test/spec_mock.rb index f49b1961..3ebd7776 100644 --- a/test/spec_mock.rb +++ b/test/spec_mock.rb @@ -30,6 +30,14 @@ describe Rack::MockRequest do env.should.include "rack.version" end + should "return an environment with a path" do + env = Rack::MockRequest.env_for("http://www.example.com/parse?location[]=1&location[]=2&age_group[]=2") + env["QUERY_STRING"].should.equal "location[]=1&location[]=2&age_group[]=2" + env["PATH_INFO"].should.equal "/parse" + env.should.be.kind_of Hash + env.should.include "rack.version" + end + should "provide sensible defaults" do res = Rack::MockRequest.new(app).request diff --git a/test/spec_multipart.rb b/test/spec_multipart.rb index 069dc4d2..2acb6e0d 100644 --- a/test/spec_multipart.rb +++ b/test/spec_multipart.rb @@ -153,6 +153,18 @@ describe Rack::Multipart do params["files"][:tempfile].read.should.equal "contents" end + should "parse multipart upload with text file with no name field" do + env = Rack::MockRequest.env_for("/", multipart_fixture(:filename_and_no_name)) + params = Rack::Multipart.parse_multipart(env) + params["file1.txt"][:type].should.equal "text/plain" + params["file1.txt"][:filename].should.equal "file1.txt" + params["file1.txt"][:head].should.equal "Content-Disposition: form-data; " + + "filename=\"file1.txt\"\r\n" + + "Content-Type: text/plain\r\n" + params["file1.txt"][:name].should.equal "file1.txt" + params["file1.txt"][:tempfile].read.should.equal "contents" + end + should "parse multipart upload with nested parameters" do env = Rack::MockRequest.env_for("/", multipart_fixture(:nested)) params = Rack::Multipart.parse_multipart(env) @@ -502,4 +514,28 @@ contents\r params["file"][:filename].should.equal('long' * 100) end + should "support mixed case metadata" do + file = multipart_file(:text) + data = File.open(file, 'rb') { |io| io.read } + + type = "Multipart/Form-Data; Boundary=AaB03x" + length = data.respond_to?(:bytesize) ? data.bytesize : data.size + + e = { "CONTENT_TYPE" => type, + "CONTENT_LENGTH" => length.to_s, + :input => StringIO.new(data) } + + env = Rack::MockRequest.env_for("/", e) + params = Rack::Multipart.parse_multipart(env) + params["submit-name"].should.equal "Larry" + params["submit-name-with-content"].should.equal "Berry" + params["files"][:type].should.equal "text/plain" + params["files"][:filename].should.equal "file1.txt" + params["files"][:head].should.equal "Content-Disposition: form-data; " + + "name=\"files\"; filename=\"file1.txt\"\r\n" + + "Content-Type: text/plain\r\n" + params["files"][:name].should.equal "files" + params["files"][:tempfile].read.should.equal "contents" + end + end diff --git a/test/spec_request.rb b/test/spec_request.rb index a3f42379..8a2b4760 100644 --- a/test/spec_request.rb +++ b/test/spec_request.rb @@ -130,6 +130,14 @@ describe Rack::Request do req.params.should.equal "foo" => "bar", "quux" => "bla" end + should "not truncate query strings containing semi-colons #543" do + req = Rack::Request.new(Rack::MockRequest.env_for("/?foo=bar&quux=b;la")) + req.query_string.should.equal "foo=bar&quux=b;la" + req.GET.should.equal "foo" => "bar", "quux" => "b;la" + req.POST.should.be.empty + req.params.should.equal "foo" => "bar", "quux" => "b;la" + end + should "limit the keys from the GET query string" do env = Rack::MockRequest.env_for("/?foo=bar") @@ -143,7 +151,7 @@ describe Rack::Request do end should "limit the key size per nested params hash" do - nested_query = Rack::MockRequest.env_for("/?foo[bar][baz][qux]=1") + nested_query = Rack::MockRequest.env_for("/?foo%5Bbar%5D%5Bbaz%5D%5Bqux%5D=1") plain_query = Rack::MockRequest.env_for("/?foo_bar__baz__qux_=1") old, Rack::Utils.key_space_limit = Rack::Utils.key_space_limit, 3 @@ -169,6 +177,18 @@ describe Rack::Request do req.params.should.equal req.GET.merge(req.POST) end + should "raise if input params has invalid %-encoding" do + mr = Rack::MockRequest.env_for("/?foo=quux", + "REQUEST_METHOD" => 'POST', + :input => "a%=1" + ) + req = Rack::Request.new mr + + lambda { req.POST }. + should.raise(Rack::Utils::InvalidParameterError). + message.should.equal "invalid %-encoding (a%)" + end + should "raise if rack.input is missing" do req = Rack::Request.new({}) lambda { req.POST }.should.raise(RuntimeError) @@ -603,7 +623,7 @@ describe Rack::Request do should "handle multiple media type parameters" do req = Rack::Request.new \ Rack::MockRequest.env_for("/", - "CONTENT_TYPE" => 'text/plain; foo=BAR,baz=bizzle dizzle;BLING=bam') + "CONTENT_TYPE" => 'text/plain; foo=BAR,baz=bizzle dizzle;BLING=bam;blong="boo";zump="zoo\"o";weird=lol"') req.should.not.be.form_data req.media_type_params.should.include 'foo' req.media_type_params['foo'].should.equal 'BAR' @@ -612,6 +632,9 @@ describe Rack::Request do req.media_type_params.should.not.include 'BLING' req.media_type_params.should.include 'bling' req.media_type_params['bling'].should.equal 'bam' + req.media_type_params['blong'].should.equal 'boo' + req.media_type_params['zump'].should.equal 'zoo\"o' + req.media_type_params['weird'].should.equal 'lol"' end should "parse with junk before boundry" do @@ -740,6 +763,31 @@ EOF req.POST["mean"][:tempfile].read.should.equal "--AaB03xha" end + should "record tempfiles from multipart form data in env[rack.tempfiles]" do + input = <<EOF +--AaB03x\r +content-disposition: form-data; name="fileupload"; filename="foo.jpg"\r +Content-Type: image/jpeg\r +Content-Transfer-Encoding: base64\r +\r +/9j/4AAQSkZJRgABAQAAAQABAAD//gA+Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcg\r +--AaB03x\r +content-disposition: form-data; name="fileupload"; filename="bar.jpg"\r +Content-Type: image/jpeg\r +Content-Transfer-Encoding: base64\r +\r +/9j/4AAQSkZJRgABAQAAAQABAAD//gA+Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcg\r +--AaB03x--\r +EOF + env = Rack::MockRequest.env_for("/", + "CONTENT_TYPE" => "multipart/form-data, boundary=AaB03x", + "CONTENT_LENGTH" => input.size, + :input => input) + req = Rack::Request.new(env) + req.params + env['rack.tempfiles'].size.should.equal(2) + end + should "detect invalid multipart form data" do input = <<EOF --AaB03x\r @@ -938,6 +986,23 @@ EOF parser.call("gzip ; deflate").should.equal([["gzip", 1.0]]) end + should "parse Accept-Language correctly" do + parser = lambda do |x| + Rack::Request.new(Rack::MockRequest.env_for("", "HTTP_ACCEPT_LANGUAGE" => x)).accept_language + end + + parser.call(nil).should.equal([]) + + parser.call("fr, en").should.equal([["fr", 1.0], ["en", 1.0]]) + parser.call("").should.equal([]) + parser.call("*").should.equal([["*", 1.0]]) + parser.call("fr;q=0.5, en;q=1.0").should.equal([["fr", 0.5], ["en", 1.0]]) + parser.call("fr;q=1.0, en; q=0.5, *;q=0").should.equal([["fr", 1.0], ["en", 0.5], ["*", 0] ]) + + parser.call("fr ; q=0.9").should.equal([["fr", 0.9]]) + parser.call("fr").should.equal([["fr", 1.0]]) + end + ip_app = lambda { |env| request = Rack::Request.new(env) response = Rack::Response.new @@ -1020,12 +1085,6 @@ EOF 'HTTP_CLIENT_IP' => '1.1.1.1' res.body.should.equal '1.1.1.1' - # Spoofing attempt - res = mock.get '/', - 'HTTP_X_FORWARDED_FOR' => '1.1.1.1', - 'HTTP_CLIENT_IP' => '2.2.2.2' - res.body.should.equal '1.1.1.1' - res = mock.get '/', 'HTTP_X_FORWARDED_FOR' => '8.8.8.8, 9.9.9.9' res.body.should.equal '9.9.9.9' @@ -1044,6 +1103,24 @@ EOF res.body.should.equal '3.4.5.6' end + should "not allow IP spoofing via Client-IP and X-Forwarded-For headers" do + mock = Rack::MockRequest.new(Rack::Lint.new(ip_app)) + + # IP Spoofing attempt: + # Client sends X-Forwarded-For: 6.6.6.6 + # Client-IP: 6.6.6.6 + # Load balancer adds X-Forwarded-For: 2.2.2.3, 192.168.0.7 + # App receives: X-Forwarded-For: 6.6.6.6 + # X-Forwarded-For: 2.2.2.3, 192.168.0.7 + # Client-IP: 6.6.6.6 + # Rack env: HTTP_X_FORWARDED_FOR: '6.6.6.6, 2.2.2.3, 192.168.0.7' + # HTTP_CLIENT_IP: '6.6.6.6' + res = mock.get '/', + 'HTTP_X_FORWARDED_FOR' => '6.6.6.6, 2.2.2.3, 192.168.0.7', + 'HTTP_CLIENT_IP' => '6.6.6.6' + res.body.should.equal '2.2.2.3' + end + should "regard local addresses as proxies" do req = Rack::Request.new(Rack::MockRequest.env_for("/")) req.trusted_proxy?('127.0.0.1').should.equal 0 @@ -1098,6 +1175,13 @@ EOF req2.params.should.equal "foo" => "bar" end + should "raise TypeError every time if request parameters are broken" do + broken_query = Rack::MockRequest.env_for("/?foo%5B%5D=0&foo%5Bbar%5D=1") + req = Rack::Request.new(broken_query) + lambda{req.GET}.should.raise(TypeError) + lambda{req.params}.should.raise(TypeError) + end + (0x20...0x7E).collect { |a| b = a.chr c = CGI.escape(b) diff --git a/test/spec_response.rb b/test/spec_response.rb index 12b8b7b3..6b13c0c9 100644 --- a/test/spec_response.rb +++ b/test/spec_response.rb @@ -223,6 +223,14 @@ describe Rack::Response do res.should.be.successful res.should.be.ok + res.status = 201 + res.should.be.successful + res.should.be.created + + res.status = 202 + res.should.be.successful + res.should.be.accepted + res.status = 400 res.should.not.be.successful res.should.be.client_error @@ -243,6 +251,11 @@ describe Rack::Response do res.should.be.client_error res.should.be.method_not_allowed + res.status = 418 + res.should.not.be.successful + res.should.be.client_error + res.should.be.i_m_a_teapot + res.status = 422 res.should.not.be.successful res.should.be.client_error diff --git a/test/spec_server.rb b/test/spec_server.rb index 44d4bcbb..01b4f562 100644 --- a/test/spec_server.rb +++ b/test/spec_server.rb @@ -30,14 +30,24 @@ describe Rack::Server do should "not include Rack::Lint in deployment or none environments" do server = Rack::Server.new(:app => 'foo') - server.middleware['deployment'].flatten.should.not.include(Rack::Lint) - server.middleware['none'].flatten.should.not.include(Rack::Lint) + server.default_middleware_by_environment['deployment'].flatten.should.not.include(Rack::Lint) + server.default_middleware_by_environment['none'].flatten.should.not.include(Rack::Lint) end should "not include Rack::ShowExceptions in deployment or none environments" do server = Rack::Server.new(:app => 'foo') - server.middleware['deployment'].flatten.should.not.include(Rack::ShowExceptions) - server.middleware['none'].flatten.should.not.include(Rack::ShowExceptions) + server.default_middleware_by_environment['deployment'].flatten.should.not.include(Rack::ShowExceptions) + server.default_middleware_by_environment['none'].flatten.should.not.include(Rack::ShowExceptions) + end + + should "always return an empty array for unknown environments" do + server = Rack::Server.new(:app => 'foo') + server.default_middleware_by_environment['production'].should.equal [] + end + + should "include Rack::TempfileReaper in deployment environment" do + server = Rack::Server.new(:app => 'foo') + server.middleware['deployment'].flatten.should.include(Rack::TempfileReaper) end should "support CGI" do @@ -53,7 +63,7 @@ describe Rack::Server do should "not force any middleware under the none configuration" do server = Rack::Server.new(:app => 'foo') - server.middleware['none'].should.be.empty + server.default_middleware_by_environment['none'].should.be.empty end should "use a full path to the pidfile" do diff --git a/test/spec_showexceptions.rb b/test/spec_showexceptions.rb index bdd5ce5b..7d50c59f 100644 --- a/test/spec_showexceptions.rb +++ b/test/spec_showexceptions.rb @@ -16,7 +16,7 @@ describe Rack::ShowExceptions do )) lambda{ - res = req.get("/") + res = req.get("/", "HTTP_ACCEPT" => "text/html") }.should.not.raise res.should.be.a.server_error @@ -26,7 +26,7 @@ describe Rack::ShowExceptions do res.should =~ /ShowExceptions/ end - it "responds with plain text on AJAX requests accepting anything but HTML" do + it "responds with HTML only to requests accepting HTML" do res = nil req = Rack::MockRequest.new( @@ -34,39 +34,32 @@ describe Rack::ShowExceptions do lambda{|env| raise RuntimeError, "It was never supposed to work" } )) - lambda{ - res = req.get("/", "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest") - }.should.not.raise - - res.should.be.a.server_error - res.status.should.equal 500 - - res.content_type.should.equal "text/plain" - - res.body.should.include "RuntimeError: It was never supposed to work\n" - res.body.should.include __FILE__ - end - - it "responds with HTML on AJAX requests accepting HTML" do - res = nil - - req = Rack::MockRequest.new( - show_exceptions( - lambda{|env| raise RuntimeError, "It was never supposed to work" } - )) - - lambda{ - res = req.get("/", "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest", "HTTP_ACCEPT" => "text/html") - }.should.not.raise - - res.should.be.a.server_error - res.status.should.equal 500 - - res.content_type.should.equal "text/html" - - res.body.should.include "RuntimeError" - res.body.should.include "It was never supposed to work" - res.body.should.include Rack::Utils.escape_html(__FILE__) + [ + # Serve text/html when the client accepts text/html + ["text/html", ["/", {"HTTP_ACCEPT" => "text/html"}]], + ["text/html", ["/", {"HTTP_ACCEPT" => "*/*"}]], + # Serve text/plain when the client does not accept text/html + ["text/plain", ["/"]], + ["text/plain", ["/", {"HTTP_ACCEPT" => "application/json"}]] + ].each do |exmime, rargs| + lambda{ + res = req.get(*rargs) + }.should.not.raise + + res.should.be.a.server_error + res.status.should.equal 500 + + res.content_type.should.equal exmime + + res.body.should.include "RuntimeError" + res.body.should.include "It was never supposed to work" + + if exmime == "text/html" + res.body.should.include '</html>' + else + res.body.should.not.include '</html>' + end + end end it "handles exceptions without a backtrace" do @@ -79,7 +72,7 @@ describe Rack::ShowExceptions do ) lambda{ - res = req.get("/") + res = req.get("/", "HTTP_ACCEPT" => "text/html") }.should.not.raise res.should.be.a.server_error diff --git a/test/spec_tempfile_reaper.rb b/test/spec_tempfile_reaper.rb new file mode 100644 index 00000000..ac39d878 --- /dev/null +++ b/test/spec_tempfile_reaper.rb @@ -0,0 +1,63 @@ +require 'rack/tempfile_reaper' +require 'rack/lint' +require 'rack/mock' + +describe Rack::TempfileReaper do + class MockTempfile + attr_reader :closed + + def initialize + @closed = false + end + + def close! + @closed = true + end + end + + before do + @env = Rack::MockRequest.env_for + end + + def call(app) + Rack::Lint.new(Rack::TempfileReaper.new(app)).call(@env) + end + + should 'do nothing (i.e. not bomb out) without env[rack.tempfiles]' do + app = lambda { |_| [200, {}, ['Hello, World!']] } + response = call(app) + response[2].close + response[0].should.equal(200) + end + + should 'close env[rack.tempfiles] when body is closed' do + tempfile1, tempfile2 = MockTempfile.new, MockTempfile.new + @env['rack.tempfiles'] = [ tempfile1, tempfile2 ] + app = lambda { |_| [200, {}, ['Hello, World!']] } + call(app)[2].close + tempfile1.closed.should.equal true + tempfile2.closed.should.equal true + end + + should 'initialize env[rack.tempfiles] when not already present' do + tempfile = MockTempfile.new + app = lambda do |env| + env['rack.tempfiles'] << tempfile + [200, {}, ['Hello, World!']] + end + call(app)[2].close + tempfile.closed.should.equal true + end + + should 'append env[rack.tempfiles] when already present' do + tempfile1, tempfile2 = MockTempfile.new, MockTempfile.new + @env['rack.tempfiles'] = [ tempfile1 ] + app = lambda do |env| + env['rack.tempfiles'] << tempfile2 + [200, {}, ['Hello, World!']] + end + call(app)[2].close + tempfile1.closed.should.equal true + tempfile2.closed.should.equal true + end +end diff --git a/test/spec_urlmap.rb b/test/spec_urlmap.rb index 316c7254..2ef41cdc 100644 --- a/test/spec_urlmap.rb +++ b/test/spec_urlmap.rb @@ -210,4 +210,27 @@ describe Rack::URLMap do res["X-PathInfo"].should.equal "/http://example.org/bar" res["X-ScriptName"].should.equal "" end + + should "not be case sensitive with hosts" do + map = Rack::Lint.new(Rack::URLMap.new("http://example.org/" => lambda { |env| + [200, + { "Content-Type" => "text/plain", + "X-Position" => "root", + "X-PathInfo" => env["PATH_INFO"], + "X-ScriptName" => env["SCRIPT_NAME"] + }, [""]]} + )) + + res = Rack::MockRequest.new(map).get("http://example.org/") + res.should.be.ok + res["X-Position"].should.equal "root" + res["X-PathInfo"].should.equal "/" + res["X-ScriptName"].should.equal "" + + res = Rack::MockRequest.new(map).get("http://EXAMPLE.ORG/") + res.should.be.ok + res["X-Position"].should.equal "root" + res["X-PathInfo"].should.equal "/" + res["X-ScriptName"].should.equal "" + end end diff --git a/test/spec_utils.rb b/test/spec_utils.rb index 622b8ff5..4b989db7 100644 --- a/test/spec_utils.rb +++ b/test/spec_utils.rb @@ -123,6 +123,17 @@ describe Rack::Utils do Rack::Utils.parse_query(",foo=bar;,", ";,").should.equal "foo" => "bar" end + should "not create infinite loops with cycle structures" do + ex = { "foo" => nil } + ex["foo"] = ex + + params = Rack::Utils::KeySpaceConstrainedParams.new + params['foo'] = params + lambda { + params.to_params_hash.to_s.should.equal ex.to_s + }.should.not.raise + end + should "parse nested query strings correctly" do Rack::Utils.parse_nested_query("foo"). should.equal "foo" => nil @@ -157,6 +168,16 @@ describe Rack::Utils do should.equal "foo" => [""] Rack::Utils.parse_nested_query("foo[]=bar"). should.equal "foo" => ["bar"] + Rack::Utils.parse_nested_query("foo[]=bar&foo"). + should.equal "foo" => nil + Rack::Utils.parse_nested_query("foo[]=bar&foo["). + should.equal "foo" => ["bar"], "foo[" => nil + Rack::Utils.parse_nested_query("foo[]=bar&foo[=baz"). + should.equal "foo" => ["bar"], "foo[" => "baz" + Rack::Utils.parse_nested_query("foo[]=bar&foo[]"). + should.equal "foo" => ["bar", nil] + Rack::Utils.parse_nested_query("foo[]=bar&foo[]="). + should.equal "foo" => ["bar", ""] Rack::Utils.parse_nested_query("foo[]=1&foo[]=2"). should.equal "foo" => ["1", "2"] @@ -192,16 +213,22 @@ describe Rack::Utils do should.equal "x" => {"y" => [{"z" => "1", "w" => "a"}, {"z" => "2", "w" => "3"}]} lambda { Rack::Utils.parse_nested_query("x[y]=1&x[y]z=2") }. - should.raise(TypeError). + should.raise(Rack::Utils::ParameterTypeError). message.should.equal "expected Hash (got String) for param `y'" lambda { Rack::Utils.parse_nested_query("x[y]=1&x[]=1") }. - should.raise(TypeError). + should.raise(Rack::Utils::ParameterTypeError). message.should.match(/expected Array \(got [^)]*\) for param `x'/) lambda { Rack::Utils.parse_nested_query("x[y]=1&x[y][][w]=2") }. - should.raise(TypeError). + should.raise(Rack::Utils::ParameterTypeError). message.should.equal "expected Array (got String) for param `y'" + + if RUBY_VERSION.to_f > 1.9 + lambda { Rack::Utils.parse_nested_query("foo%81E=1") }. + should.raise(Rack::Utils::InvalidParameterError). + message.should.equal "invalid byte sequence in UTF-8" + end end should "build query strings correctly" do @@ -290,9 +317,15 @@ describe Rack::Utils do # Higher quality matches are preferred Rack::Utils.best_q_match("text/*;q=0.5,text/plain;q=1.0", %w[text/plain text/html]).should.equal "text/plain" + # Respect requested content type + Rack::Utils.best_q_match("application/json", %w[application/vnd.lotus-1-2-3 application/json]).should.equal "application/json" + # All else equal, the available mimes are preferred in order Rack::Utils.best_q_match("text/*", %w[text/html text/plain]).should.equal "text/html" Rack::Utils.best_q_match("text/plain,text/html", %w[text/html text/plain]).should.equal "text/html" + + # When there are no matches, return nil: + Rack::Utils.best_q_match("application/json", %w[text/html text/plain]).should.equal nil end should "escape html entities [&><'\"/]" do @@ -378,6 +411,25 @@ describe Rack::Utils do should "return rfc2109 format from rfc2109 helper" do Rack::Utils.rfc2109(Time.at(0).gmtime).should == "Thu, 01-Jan-1970 00:00:00 GMT" end + + should "clean directory traversal" do + Rack::Utils.clean_path_info("/cgi/../cgi/test").should.equal "/cgi/test" + Rack::Utils.clean_path_info(".").should.empty + Rack::Utils.clean_path_info("test/..").should.empty + end + + should "clean unsafe directory traversal to safe path" do + Rack::Utils.clean_path_info("/../README.rdoc").should.equal "/README.rdoc" + Rack::Utils.clean_path_info("../test/spec_utils.rb").should.equal "test/spec_utils.rb" + end + + should "not clean directory traversal with encoded periods" do + Rack::Utils.clean_path_info("/%2E%2E/README").should.equal "/%2E%2E/README" + end + + should "clean slash only paths" do + Rack::Utils.clean_path_info("/").should.equal "/" + end end describe Rack::Utils, "byte_range" do diff --git a/test/spec_webrick.rb b/test/spec_webrick.rb index b29a82d5..497bfe20 100644 --- a/test/spec_webrick.rb +++ b/test/spec_webrick.rb @@ -162,5 +162,23 @@ describe Rack::Handler::WEBrick do } end + should "produce correct HTTP semantics with and without app chunking" do + @server.mount "/chunked", Rack::Handler::WEBrick, + Rack::Lint.new(lambda{ |req| + [ + 200, + {"Transfer-Encoding" => "chunked"}, + ["7\r\nchunked\r\n0\r\n\r\n"] + ] + }) + + Net::HTTP.start(@host, @port){ |http| + res = http.get("/chunked") + res["Transfer-Encoding"].should.equal "chunked" + res["Content-Length"].should.equal nil + res.body.should.equal "chunked" + } + end + @server.shutdown end |