diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index a89fa6d1737698609c76fc17741a70de68ec88e1..0000000000000000000000000000000000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,107 +0,0 @@ -workflows: - version: 2 - - test: - jobs: - - test-jruby - - test-ruby-2.2 - - test-ruby-2.3 - - test-ruby-2.4 - - test-ruby-2.5 - - test-ruby-2.6 - - test-ruby-2.7 - -version: 2 - -default-steps: &default-steps - - checkout - - run: sudo apt-get install lighttpd libfcgi-dev libmemcached-dev - - # Restore bundle cache - - type: cache-restore - key: rack-{{ checksum "rack.gemspec" }}-{{ checksum "Gemfile" }} - - # Bundle install dependencies - - run: bundle install --path vendor/bundle - - # Store bundle cache - - type: cache-save - key: rack-{{ checksum "rack.gemspec" }}-{{ checksum "Gemfile" }} - paths: - - vendor/bundle - - - run: bundle exec rubocop - - - run: bundle exec rake ci - -jobs: - test-ruby-2.2: - docker: - - image: circleci/ruby:2.2 - # Spawn a process owned by root - # This works around an issue explained here: - # https://github.com/circleci/circleci-images/pull/132 - command: sudo /bin/sh - - image: memcached:1.4 - steps: *default-steps - - test-ruby-2.3: - docker: - - image: circleci/ruby:2.3 - # Spawn a process owned by root - # This works around an issue explained here: - # https://github.com/circleci/circleci-images/pull/132 - command: sudo /bin/sh - - image: memcached:1.4 - steps: *default-steps - - test-ruby-2.4: - docker: - - image: circleci/ruby:2.4 - # Spawn a process owned by root - # This works around an issue explained here: - # https://github.com/circleci/circleci-images/pull/132 - command: sudo /bin/sh - - image: memcached:1.4 - steps: *default-steps - - test-ruby-2.5: - docker: - - image: circleci/ruby:2.5 - # Spawn a process owned by root - # This works around an issue explained here: - # https://github.com/circleci/circleci-images/pull/132 - command: sudo /bin/sh - - image: memcached:1.4 - steps: *default-steps - - test-ruby-2.6: - docker: - - image: circleci/ruby:2.6 - # Spawn a process owned by root - # This works around an issue explained here: - # https://github.com/circleci/circleci-images/pull/132 - command: sudo /bin/sh - - image: memcached:1.4 - steps: *default-steps - - test-ruby-2.7: - docker: - - image: circleci/ruby:2.7 - # Spawn a process owned by root - # This works around an issue explained here: - # https://github.com/circleci/circleci-images/pull/132 - command: sudo /bin/sh - - image: memcached:1.4 - steps: *default-steps - - test-jruby: - docker: - - image: circleci/jruby - # Spawn a process owned by root - # This works around an issue explained here: - # https://github.com/circleci/circleci-images/pull/132 - command: sudo /bin/sh - - image: memcached:1.4 - steps: *default-steps - diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml new file mode 100644 index 0000000000000000000000000000000000000000..0757016be374eed62379ca5819970b0ff73ecc98 --- /dev/null +++ b/.github/workflows/development.yml @@ -0,0 +1,35 @@ +name: Development + +on: [push, pull_request] + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-20.04] + ruby: [2.3, 2.4, 2.5, 2.6, 2.7, '3.0', 3.1, 3.2] + runs-on: ${{matrix.os}} + steps: + - uses: actions/checkout@v2 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{matrix.ruby}} + + - uses: actions/cache@v1 + with: + path: vendor/bundle + key: bundle-use-ruby-${{matrix.os}}-${{matrix.ruby}}-${{hashFiles('**/Gemfile')}} + restore-keys: | + bundle-use-ruby-${{matrix.os}}-${{matrix.ruby}}- + + - name: Installing packages + run: sudo apt-get install libfcgi-dev libmemcached-dev + + - name: Bundle install... + run: | + bundle config path vendor/bundle + bundle install + + - run: bundle exec rake diff --git a/.gitignore b/.gitignore index 7a7ad3a55d7fca35fce01e6b8280538f2e113710..611ea3d9f7d6f282761e6f4aa5ab81efc00f9d03 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ Gemfile.lock doc /.bundle /.yardoc +/coverage diff --git a/.rubocop.yml b/.rubocop.yml index 22ed992086ac71f876125e8b1bc2a3c6f3b3ed26..ca9867670d647b8b73c5cb9ab1c7c86941948ab1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,5 @@ AllCops: - TargetRubyVersion: 2.2 + TargetRubyVersion: 2.3 DisabledByDefault: true Exclude: - '**/vendor/**/*' @@ -14,6 +14,9 @@ Style/FrozenStringLiteralComment: Style/HashSyntax: Enabled: true +Style/MethodDefParentheses: + Enabled: true + Layout/EmptyLineAfterMagicComment: Enabled: true @@ -46,3 +49,9 @@ Layout/SpaceBeforeFirstArg: # Use `{ a: 1 }` not `{a:1}`. Layout/SpaceInsideHashLiteralBraces: Enabled: true + +Layout/Tab: + Enabled: true + +Layout/TrailingWhitespace: + Enabled: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f0be3424ea7b43a7efd459771e0241eae154a84..85cb1fc2a65504b9ccaab168e05d3939182e0c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,151 @@ -## [2.1.4] - 2020-06-15 +# Changelog -- [CVE-2020-8184] When parsing cookies, only decode the value +All notable changes to this project will be documented in this file. For info on how to format all future additions to this file please reference [Keep A Changelog](https://keepachangelog.com/en/1.0.0/). -## [2.1.3] - 2020-05-12 +## [2.2.6.4] - 2023-03-13 + +- [CVE-2023-27539] Avoid ReDoS in header parsing + +## [2.2.6.3] - 2023-03-02 + +- [CVE-2023-27530] Introduce multipart_total_part_limit to limit total parts + +## [2.2.6.2] - 2022-01-17 + +- [CVE-2022-44570] Fix ReDoS in Rack::Utils.get_byte_ranges + +## [2.2.6.1] - 2022-01-17 + +- [CVE-2022-44571] Fix ReDoS vulnerability in multipart parser +- [CVE-2022-44572] Forbid control characters in attributes (also ReDoS) + +## [2.2.6] - 2022-01-17 + +- Extend `Rack::MethodOverride` to handle `QueryParser::ParamsTooDeepError` error. ([#2011](https://github.com/rack/rack/pull/2011), [@byroot](https://github.com/byroot)) + +## [2.2.5] - 2022-12-27 + +### Fixed + +- `Rack::URLMap` uses non-deprecated form of `Regexp.new`. ([#1998](https://github.com/rack/rack/pull/1998), [@weizheheng](https://github.com/weizheheng)) + +## [2.2.4] - 2022-06-30 + +- Better support for lower case headers in `Rack::ETag` middleware. ([#1919](https://github.com/rack/rack/pull/1919), [@ioquatix](https://github.com/ioquatix)) +- Use custom exception on params too deep error. ([#1838](https://github.com/rack/rack/pull/1838), [@simi](https://github.com/simi)) + +## [2.2.3.1] - 2022-05-27 + +### Security + +- [CVE-2022-30123] Fix shell escaping issue in Common Logger +- [CVE-2022-30122] Restrict parsing of broken MIME attachments + +## [2.2.3] - 2020-02-11 + +### Security + +- [CVE-2020-8184] Only decode cookie values + +## [2.2.2] - 2020-02-11 + +### Fixed + +- Fix incorrect `Rack::Request#host` value. ([#1591](https://github.com/rack/rack/pull/1591), [@ioquatix](https://github.com/ioquatix)) +- Revert `Rack::Handler::Thin` implementation. ([#1583](https://github.com/rack/rack/pull/1583), [@jeremyevans](https://github.com/jeremyevans)) +- Double assignment is still needed to prevent an "unused variable" warning. ([#1589](https://github.com/rack/rack/pull/1589), [@kamipo](https://github.com/kamipo)) +- Fix to handle same_site option for session pool. ([#1587](https://github.com/rack/rack/pull/1587), [@kamipo](https://github.com/kamipo)) + +## [2.2.1] - 2020-02-09 + +### Fixed + +- Rework `Rack::Request#ip` to handle empty `forwarded_for`. ([#1577](https://github.com/rack/rack/pull/1577), [@ioquatix](https://github.com/ioquatix)) + +## [2.2.0] - 2020-02-08 + +### SPEC Changes + +- `rack.session` request environment entry must respond to `to_hash` and return unfrozen Hash. ([@jeremyevans](https://github.com/jeremyevans)) +- Request environment cannot be frozen. ([@jeremyevans](https://github.com/jeremyevans)) +- CGI values in the request environment with non-ASCII characters must use ASCII-8BIT encoding. ([@jeremyevans](https://github.com/jeremyevans)) +- Improve SPEC/lint relating to SERVER_NAME, SERVER_PORT and HTTP_HOST. ([#1561](https://github.com/rack/rack/pull/1561), [@ioquatix](https://github.com/ioquatix)) + +### Added + +- `rackup` supports multiple `-r` options and will require all arguments. ([@jeremyevans](https://github.com/jeremyevans)) +- `Server` supports an array of paths to require for the `:require` option. ([@khotta](https://github.com/khotta)) +- `Files` supports multipart range requests. ([@fatkodima](https://github.com/fatkodima)) +- `Multipart::UploadedFile` supports an IO-like object instead of using the filesystem, using `:filename` and `:io` options. ([@jeremyevans](https://github.com/jeremyevans)) +- `Multipart::UploadedFile` supports keyword arguments `:path`, `:content_type`, and `:binary` in addition to positional arguments. ([@jeremyevans](https://github.com/jeremyevans)) +- `Static` supports a `:cascade` option for calling the app if there is no matching file. ([@jeremyevans](https://github.com/jeremyevans)) +- `Session::Abstract::SessionHash#dig`. ([@jeremyevans](https://github.com/jeremyevans)) +- `Response.[]` and `MockResponse.[]` for creating instances using status, headers, and body. ([@ioquatix](https://github.com/ioquatix)) +- Convenient cache and content type methods for `Rack::Response`. ([#1555](https://github.com/rack/rack/pull/1555), [@ioquatix](https://github.com/ioquatix)) + +### Changed + +- `Request#params` no longer rescues EOFError. ([@jeremyevans](https://github.com/jeremyevans)) +- `Directory` uses a streaming approach, significantly improving time to first byte for large directories. ([@jeremyevans](https://github.com/jeremyevans)) +- `Directory` no longer includes a Parent directory link in the root directory index. ([@jeremyevans](https://github.com/jeremyevans)) +- `QueryParser#parse_nested_query` uses original backtrace when reraising exception with new class. ([@jeremyevans](https://github.com/jeremyevans)) +- `ConditionalGet` follows RFC 7232 precedence if both If-None-Match and If-Modified-Since headers are provided. ([@jeremyevans](https://github.com/jeremyevans)) +- `.ru` files supports the `frozen-string-literal` magic comment. ([@eregon](https://github.com/eregon)) +- Rely on autoload to load constants instead of requiring internal files, make sure to require 'rack' and not just 'rack/...'. ([@jeremyevans](https://github.com/jeremyevans)) +- `Etag` will continue sending ETag even if the response should not be cached. ([@henm](https://github.com/henm)) +- `Request#host_with_port` no longer includes a colon for a missing or empty port. ([@AlexWayfer](https://github.com/AlexWayfer)) +- All handlers uses keywords arguments instead of an options hash argument. ([@ioquatix](https://github.com/ioquatix)) +- `Files` handling of range requests no longer return a body that supports `to_path`, to ensure range requests are handled correctly. ([@jeremyevans](https://github.com/jeremyevans)) +- `Multipart::Generator` only includes `Content-Length` for files with paths, and `Content-Disposition` `filename` if the `UploadedFile` instance has one. ([@jeremyevans](https://github.com/jeremyevans)) +- `Request#ssl?` is true for the `wss` scheme (secure websockets). ([@jeremyevans](https://github.com/jeremyevans)) +- `Rack::HeaderHash` is memoized by default. ([#1549](https://github.com/rack/rack/pull/1549), [@ioquatix](https://github.com/ioquatix)) +- `Rack::Directory` allow directory traversal inside root directory. ([#1417](https://github.com/rack/rack/pull/1417), [@ThomasSevestre](https://github.com/ThomasSevestre)) +- Sort encodings by server preference. ([#1184](https://github.com/rack/rack/pull/1184), [@ioquatix](https://github.com/ioquatix), [@wjordan](https://github.com/wjordan)) +- Rework host/hostname/authority implementation in `Rack::Request`. `#host` and `#host_with_port` have been changed to correctly return IPv6 addresses formatted with square brackets, as defined by [RFC3986](https://tools.ietf.org/html/rfc3986#section-3.2.2). ([#1561](https://github.com/rack/rack/pull/1561), [@ioquatix](https://github.com/ioquatix)) +- `Rack::Builder` parsing options on first `#\` line is deprecated. ([#1574](https://github.com/rack/rack/pull/1574), [@ioquatix](https://github.com/ioquatix)) + +### Removed + +- `Directory#path` as it was not used and always returned nil. ([@jeremyevans](https://github.com/jeremyevans)) +- `BodyProxy#each` as it was only needed to work around a bug in Ruby <1.9.3. ([@jeremyevans](https://github.com/jeremyevans)) +- `URLMap::INFINITY` and `URLMap::NEGATIVE_INFINITY`, in favor of `Float::INFINITY`. ([@ch1c0t](https://github.com/ch1c0t)) +- Deprecation of `Rack::File`. It will be deprecated again in rack 2.2 or 3.0. ([@rafaelfranca](https://github.com/rafaelfranca)) +- Support for Ruby 2.2 as it is well past EOL. ([@ioquatix](https://github.com/ioquatix)) +- Remove `Rack::Files#response_body` as the implementation was broken. ([#1153](https://github.com/rack/rack/pull/1153), [@ioquatix](https://github.com/ioquatix)) +- Remove `SERVER_ADDR` which was never part of the original SPEC. ([#1573](https://github.com/rack/rack/pull/1573), [@ioquatix](https://github.com/ioquatix)) + +### Fixed + +- `Directory` correctly handles root paths containing glob metacharacters. ([@jeremyevans](https://github.com/jeremyevans)) +- `Cascade` uses a new response object for each call if initialized with no apps. ([@jeremyevans](https://github.com/jeremyevans)) +- `BodyProxy` correctly delegates keyword arguments to the body object on Ruby 2.7+. ([@jeremyevans](https://github.com/jeremyevans)) +- `BodyProxy#method` correctly handles methods delegated to the body object. ([@jeremyevans](https://github.com/jeremyevans)) +- `Request#host` and `Request#host_with_port` handle IPv6 addresses correctly. ([@AlexWayfer](https://github.com/AlexWayfer)) +- `Lint` checks when response hijacking that `rack.hijack` is called with a valid object. ([@jeremyevans](https://github.com/jeremyevans)) +- `Response#write` correctly updates `Content-Length` if initialized with a body. ([@jeremyevans](https://github.com/jeremyevans)) +- `CommonLogger` includes `SCRIPT_NAME` when logging. ([@Erol](https://github.com/Erol)) +- `Utils.parse_nested_query` correctly handles empty queries, using an empty instance of the params class instead of a hash. ([@jeremyevans](https://github.com/jeremyevans)) +- `Directory` correctly escapes paths in links. ([@yous](https://github.com/yous)) +- `Request#delete_cookie` and related `Utils` methods handle `:domain` and `:path` options in same call. ([@jeremyevans](https://github.com/jeremyevans)) +- `Request#delete_cookie` and related `Utils` methods do an exact match on `:domain` and `:path` options. ([@jeremyevans](https://github.com/jeremyevans)) +- `Static` no longer adds headers when a gzipped file request has a 304 response. ([@chooh](https://github.com/chooh)) +- `ContentLength` sets `Content-Length` response header even for bodies not responding to `to_ary`. ([@jeremyevans](https://github.com/jeremyevans)) +- Thin handler supports options passed directly to `Thin::Controllers::Controller`. ([@jeremyevans](https://github.com/jeremyevans)) +- WEBrick handler no longer ignores `:BindAddress` option. ([@jeremyevans](https://github.com/jeremyevans)) +- `ShowExceptions` handles invalid POST data. ([@jeremyevans](https://github.com/jeremyevans)) +- Basic authentication requires a password, even if the password is empty. ([@jeremyevans](https://github.com/jeremyevans)) +- `Lint` checks response is array with 3 elements, per SPEC. ([@jeremyevans](https://github.com/jeremyevans)) +- Support for using `:SSLEnable` option when using WEBrick handler. (Gregor Melhorn) +- Close response body after buffering it when buffering. ([@ioquatix](https://github.com/ioquatix)) +- Only accept `;` as delimiter when parsing cookies. ([@mrageh](https://github.com/mrageh)) +- `Utils::HeaderHash#clear` clears the name mapping as well. ([@raxoft](https://github.com/raxoft)) +- Support for passing `nil` `Rack::Files.new`, which notably fixes Rails' current `ActiveStorage::FileServer` implementation. ([@ioquatix](https://github.com/ioquatix)) + +### Documentation + +- CHANGELOG updates. ([@aupajo](https://github.com/aupajo)) +- Added [CONTRIBUTING](CONTRIBUTING.md). ([@dblock](https://github.com/dblock)) -- [CVE-2020-8161] Use Dir.entries instead of Dir[glob] to prevent user-specified glob metacharacters -- ## [2.1.2] - 2020-01-27 - Fix multipart parser for some files to prevent denial of service ([@aiomaster](https://github.com/aiomaster)) @@ -18,6 +158,7 @@ ## [2.1.1] - 2020-01-12 - Remove `Rack::Chunked` from `Rack::Server` default middleware. ([#1475](https://github.com/rack/rack/pull/1475), [@ioquatix](https://github.com/ioquatix)) +- Restore support for code relying on `SessionId#to_s`. ([@jeremyevans](https://github.com/jeremyevans)) ## [2.1.0] - 2020-01-10 @@ -71,7 +212,524 @@ ### Documentation - Update broken example in `Session::Abstract::ID` documentation. ([tonytonyjan](https://github.com/tonytonyjan)) -- Add Padrino to the list of frameworks implmenting Rack. ([@wikimatze](https://github.com/wikimatze)) +- Add Padrino to the list of frameworks implementing Rack. ([@wikimatze](https://github.com/wikimatze)) - Remove Mongrel from the suggested server options in the help output. ([@tricknotes](https://github.com/tricknotes)) - Replace `HISTORY.md` and `NEWS.md` with `CHANGELOG.md`. ([@twitnithegirl](https://github.com/twitnithegirl)) - CHANGELOG updates. ([@drenmi](https://github.com/Drenmi), [@p8](https://github.com/p8)) + +## [2.0.8] - 2019-12-08 + +### Security + +- [[CVE-2019-16782](https://nvd.nist.gov/vuln/detail/CVE-2019-16782)] Prevent timing attacks targeted at session ID lookup. BREAKING CHANGE: Session ID is now a SessionId instance instead of a String. ([@tenderlove](https://github.com/tenderlove), [@rafaelfranca](https://github.com/rafaelfranca)) + +## [1.6.12] - 2019-12-08 + +### Security + +- [[CVE-2019-16782](https://nvd.nist.gov/vuln/detail/CVE-2019-16782)] Prevent timing attacks targeted at session ID lookup. BREAKING CHANGE: Session ID is now a SessionId instance instead of a String. ([@tenderlove](https://github.com/tenderlove), [@rafaelfranca](https://github.com/rafaelfranca)) + +## [2.0.7] - 2019-04-02 + +### Fixed + +- Remove calls to `#eof?` on Rack input in `Multipart::Parser`, as this breaks the specification. ([@matthewd](https://github.com/matthewd)) +- Preserve forwarded IP addresses for trusted proxy chains. ([@SamSaffron](https://github.com/SamSaffron)) + +## [2.0.6] - 2018-11-05 + +### Fixed + +- [[CVE-2018-16470](https://nvd.nist.gov/vuln/detail/CVE-2018-16470)] Reduce buffer size of `Multipart::Parser` to avoid pathological parsing. ([@tenderlove](https://github.com/tenderlove)) +- Fix a call to a non-existing method `#accepts_html` in the `ShowExceptions` middleware. ([@tomelm](https://github.com/tomelm)) +- [[CVE-2018-16471](https://nvd.nist.gov/vuln/detail/CVE-2018-16471)] Whitelist HTTP and HTTPS schemes in `Request#scheme` to prevent a possible XSS attack. ([@PatrickTulskie](https://github.com/PatrickTulskie)) + +## [2.0.5] - 2018-04-23 + +### Fixed + +- Record errors originating from invalid UTF8 in `MethodOverride` middleware instead of breaking. ([@mclark](https://github.com/mclark)) + +## [2.0.4] - 2018-01-31 + +### Changed + +- Ensure the `Lock` middleware passes the original `env` object. ([@lugray](https://github.com/lugray)) +- Improve performance of `Multipart::Parser` when uploading large files. ([@tompng](https://github.com/tompng)) +- Increase buffer size in `Multipart::Parser` for better performance. ([@jkowens](https://github.com/jkowens)) +- Reduce memory usage of `Multipart::Parser` when uploading large files. ([@tompng](https://github.com/tompng)) +- Replace ConcurrentRuby dependency with native `Queue`. ([@devmchakan](https://github.com/devmchakan)) + +### Fixed + +- Require the correct digest algorithm in the `ETag` middleware. ([@matthewd](https://github.com/matthewd)) + +### Documentation + +- Update homepage links to use SSL. ([@hugoabonizio](https://github.com/hugoabonizio)) + +## [2.0.3] - 2017-05-15 + +### Changed + +- Ensure `env` values are ASCII 8-bit encoded. ([@eileencodes](https://github.com/eileencodes)) + +### Fixed + +- Prevent exceptions when a class with mixins inherits from `Session::Abstract::ID`. ([@jnraine](https://github.com/jnraine)) + +## [2.0.2] - 2017-05-08 + +### Added + +- Allow `Session::Abstract::SessionHash#fetch` to accept a block with a default value. ([@yannvanhalewyn](https://github.com/yannvanhalewyn)) +- Add `Builder#freeze_app` to freeze application and all middleware. ([@jeremyevans](https://github.com/jeremyevans)) + +### Changed + +- Freeze default session options to avoid accidental mutation. ([@kirs](https://github.com/kirs)) +- Detect partial hijack without hash headers. ([@devmchakan](https://github.com/devmchakan)) +- Update tests to use MiniTest 6 matchers. ([@tonytonyjan](https://github.com/tonytonyjan)) +- Allow 205 Reset Content responses to set a Content-Length, as RFC 7231 proposes setting this to 0. ([@devmchakan](https://github.com/devmchakan)) + +### Fixed + +- Handle `NULL` bytes in multipart filenames. ([@casperisfine](https://github.com/casperisfine)) +- Remove warnings due to miscapitalized global. ([@ioquatix](https://github.com/ioquatix)) +- Prevent exceptions caused by a race condition on multi-threaded servers. ([@sophiedeziel](https://github.com/sophiedeziel)) +- Add RDoc as an explicit depencency for `doc` group. ([@tonytonyjan](https://github.com/tonytonyjan)) +- Record errors originating from `Multipart::Parser` in the `MethodOverride` middleware instead of letting them bubble up. ([@carlzulauf](https://github.com/carlzulauf)) +- Remove remaining use of removed `Utils#bytesize` method from the `File` middleware. ([@brauliomartinezlm](https://github.com/brauliomartinezlm)) + +### Removed + +- Remove `deflate` encoding support to reduce caching overhead. ([@devmchakan](https://github.com/devmchakan)) + +### Documentation + +- Update broken example in `Deflater` documentation. ([@mwpastore](https://github.com/mwpastore)) + +## [2.0.1] - 2016-06-30 + +### Changed + +- Remove JSON as an explicit dependency. ([@mperham](https://github.com/mperham)) + + +# History/News Archive +Items below this line are from the previously maintained HISTORY.md and NEWS.md files. + +## [2.0.0.rc1] 2016-05-06 +- Rack::Session::Abstract::ID is deprecated. Please change to use Rack::Session::Abstract::Persisted + +## [2.0.0.alpha] 2015-12-04 +- First-party "SameSite" cookies. Browsers omit SameSite cookies from third-party requests, closing the door on many CSRF attacks. +- Pass `same_site: true` (or `:strict`) to enable: response.set_cookie 'foo', value: 'bar', same_site: true or `same_site: :lax` to use Lax enforcement: response.set_cookie 'foo', value: 'bar', same_site: :lax +- Based on version 7 of the Same-site Cookies internet draft: + https://tools.ietf.org/html/draft-west-first-party-cookies-07 +- Thanks to Ben Toews (@mastahyeti) and Bob Long (@bobjflong) for updating to drafts 5 and 7. +- Add `Rack::Events` middleware for adding event based middleware: middleware that does not care about the response body, but only cares about doing work at particular points in the request / response lifecycle. +- Add `Rack::Request#authority` to calculate the authority under which the response is being made (this will be handy for h2 pushes). +- Add `Rack::Response::Helpers#cache_control` and `cache_control=`. Use this for setting cache control headers on your response objects. +- Add `Rack::Response::Helpers#etag` and `etag=`. Use this for setting etag values on the response. +- Introduce `Rack::Response::Helpers#add_header` to add a value to a multi-valued response header. Implemented in terms of other `Response#*_header` methods, so it's available to any response-like class that includes the `Helpers` module. +- Add `Rack::Request#add_header` to match. +- `Rack::Session::Abstract::ID` IS DEPRECATED. Please switch to `Rack::Session::Abstract::Persisted`. `Rack::Session::Abstract::Persisted` uses a request object rather than the `env` hash. +- Pull `ENV` access inside the request object in to a module. This will help with legacy Request objects that are ENV based but don't want to inherit from Rack::Request +- Move most methods on the `Rack::Request` to a module `Rack::Request::Helpers` and use public API to get values from the request object. This enables users to mix `Rack::Request::Helpers` in to their own objects so they can implement `(get|set|fetch|each)_header` as they see fit (for example a proxy object). +- Files and directories with + in the name are served correctly. Rather than unescaping paths like a form, we unescape with a URI parser using `Rack::Utils.unescape_path`. Fixes #265 +- Tempfiles are automatically closed in the case that there were too + many posted. +- Added methods for manipulating response headers that don't assume + they're stored as a Hash. Response-like classes may include the + Rack::Response::Helpers module if they define these methods: + - Rack::Response#has_header? + - Rack::Response#get_header + - Rack::Response#set_header + - Rack::Response#delete_header +- Introduce Util.get_byte_ranges that will parse the value of the HTTP_RANGE string passed to it without depending on the `env` hash. `byte_ranges` is deprecated in favor of this method. +- Change Session internals to use Request objects for looking up session information. This allows us to only allocate one request object when dealing with session objects (rather than doing it every time we need to manipulate cookies, etc). +- Add `Rack::Request#initialize_copy` so that the env is duped when the request gets duped. +- Added methods for manipulating request specific data. This includes + data set as CGI parameters, and just any arbitrary data the user wants + to associate with a particular request. New methods: + - Rack::Request#has_header? + - Rack::Request#get_header + - Rack::Request#fetch_header + - Rack::Request#each_header + - Rack::Request#set_header + - Rack::Request#delete_header +- lib/rack/utils.rb: add a method for constructing "delete" cookie + headers. This allows us to construct cookie headers without depending + on the side effects of mutating a hash. +- Prevent extremely deep parameters from being parsed. CVE-2015-3225 + +## [1.6.1] 2015-05-06 + - Fix CVE-2014-9490, denial of service attack in OkJson + - Use a monotonic time for Rack::Runtime, if available + - RACK_MULTIPART_LIMIT changed to RACK_MULTIPART_PART_LIMIT (RACK_MULTIPART_LIMIT is deprecated and will be removed in 1.7.0) + +## [1.5.3] 2015-05-06 + - Fix CVE-2014-9490, denial of service attack in OkJson + - Backport bug fixes to 1.5 series + +## [1.6.0] 2014-01-18 + - Response#unauthorized? helper + - Deflater now accepts an options hash to control compression on a per-request level + - Builder#warmup method for app preloading + - Request#accept_language method to extract HTTP_ACCEPT_LANGUAGE + - Add quiet mode of rack server, rackup --quiet + - Update HTTP Status Codes to RFC 7231 + - Less strict header name validation according to RFC 2616 + - SPEC updated to specify headers conform to RFC7230 specification + - Etag correctly marks etags as weak + - Request#port supports multiple x-http-forwarded-proto values + - Utils#multipart_part_limit configures the maximum number of parts a request can contain + - Default host to localhost when in development mode + - Various bugfixes and performance improvements + +## [1.5.2] 2013-02-07 + - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie + - Fix CVE-2013-0262, symlink path traversal in Rack::File + - Add various methods to Session for enhanced Rails compatibility + - Request#trusted_proxy? now only matches whole strings + - Add JSON cookie coder, to be default in Rack 1.6+ due to security concerns + - URLMap host matching in environments that don't set the Host header fixed + - Fix a race condition that could result in overwritten pidfiles + - Various documentation additions + +## [1.4.5] 2013-02-07 + - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie + - Fix CVE-2013-0262, symlink path traversal in Rack::File + +## [1.1.6, 1.2.8, 1.3.10] 2013-02-07 + - Fix CVE-2013-0263, timing attack against Rack::Session::Cookie + +## [1.5.1] 2013-01-28 + - Rack::Lint check_hijack now conforms to other parts of SPEC + - Added hash-like methods to Abstract::ID::SessionHash for compatibility + - Various documentation corrections + +## [1.5.0] 2013-01-21 + - Introduced hijack SPEC, for before-response and after-response hijacking + - SessionHash is no longer a Hash subclass + - Rack::File cache_control parameter is removed, in place of headers options + - Rack::Auth::AbstractRequest#scheme now yields strings, not symbols + - Rack::Utils cookie functions now format expires in RFC 2822 format + - Rack::File now has a default mime type + - rackup -b 'run Rack::Files.new(".")', option provides command line configs + - Rack::Deflater will no longer double encode bodies + - Rack::Mime#match? provides convenience for Accept header matching + - Rack::Utils#q_values provides splitting for Accept headers + - Rack::Utils#best_q_match provides a helper for Accept headers + - Rack::Handler.pick provides convenience for finding available servers + - Puma added to the list of default servers (preferred over Webrick) + - Various middleware now correctly close body when replacing it + - Rack::Request#params is no longer persistent with only GET params + - Rack::Request#update_param and #delete_param provide persistent operations + - Rack::Request#trusted_proxy? now returns true for local unix sockets + - Rack::Response no longer forces Content-Types + - Rack::Sendfile provides local mapping configuration options + - Rack::Utils#rfc2109 provides old netscape style time output + - Updated HTTP status codes + - Ruby 1.8.6 likely no longer passes tests, and is no longer fully supported + +## [1.4.4, 1.3.9, 1.2.7, 1.1.5] 2013-01-13 + - [SEC] Rack::Auth::AbstractRequest no longer symbolizes arbitrary strings + - Fixed erroneous test case in the 1.3.x series + +## [1.4.3] 2013-01-07 + - Security: Prevent unbounded reads in large multipart boundaries + +## [1.3.8] 2013-01-07 + - Security: Prevent unbounded reads in large multipart boundaries + +## [1.4.2] 2013-01-06 + - Add warnings when users do not provide a session secret + - Fix parsing performance for unquoted filenames + - Updated URI backports + - Fix URI backport version matching, and silence constant warnings + - Correct parameter parsing with empty values + - Correct rackup '-I' flag, to allow multiple uses + - Correct rackup pidfile handling + - Report rackup line numbers correctly + - Fix request loops caused by non-stale nonces with time limits + - Fix reloader on Windows + - Prevent infinite recursions from Response#to_ary + - Various middleware better conforms to the body close specification + - Updated language for the body close specification + - Additional notes regarding ECMA escape compatibility issues + - Fix the parsing of multiple ranges in range headers + - Prevent errors from empty parameter keys + - Added PATCH verb to Rack::Request + - Various documentation updates + - Fix session merge semantics (fixes rack-test) + - Rack::Static :index can now handle multiple directories + - All tests now utilize Rack::Lint (special thanks to Lars Gierth) + - Rack::File cache_control parameter is now deprecated, and removed by 1.5 + - Correct Rack::Directory script name escaping + - Rack::Static supports header rules for sophisticated configurations + - Multipart parsing now works without a Content-Length header + - New logos courtesy of Zachary Scott! + - Rack::BodyProxy now explicitly defines #each, useful for C extensions + - Cookies that are not URI escaped no longer cause exceptions + +## [1.3.7] 2013-01-06 + - Add warnings when users do not provide a session secret + - Fix parsing performance for unquoted filenames + - Updated URI backports + - Fix URI backport version matching, and silence constant warnings + - Correct parameter parsing with empty values + - Correct rackup '-I' flag, to allow multiple uses + - Correct rackup pidfile handling + - Report rackup line numbers correctly + - Fix request loops caused by non-stale nonces with time limits + - Fix reloader on Windows + - Prevent infinite recursions from Response#to_ary + - Various middleware better conforms to the body close specification + - Updated language for the body close specification + - Additional notes regarding ECMA escape compatibility issues + - Fix the parsing of multiple ranges in range headers + +## [1.2.6] 2013-01-06 + - Add warnings when users do not provide a session secret + - Fix parsing performance for unquoted filenames + +## [1.1.4] 2013-01-06 + - Add warnings when users do not provide a session secret + +## [1.4.1] 2012-01-22 + - Alter the keyspace limit calculations to reduce issues with nested params + - Add a workaround for multipart parsing where files contain unescaped "%" + - Added Rack::Response::Helpers#method_not_allowed? (code 405) + - Rack::File now returns 404 for illegal directory traversals + - Rack::File now returns 405 for illegal methods (non HEAD/GET) + - Rack::Cascade now catches 405 by default, as well as 404 + - Cookies missing '--' no longer cause an exception to be raised + - Various style changes and documentation spelling errors + - Rack::BodyProxy always ensures to execute its block + - Additional test coverage around cookies and secrets + - Rack::Session::Cookie can now be supplied either secret or old_secret + - Tests are no longer dependent on set order + - Rack::Static no longer defaults to serving index files + - Rack.release was fixed + +## [1.4.0] 2011-12-28 + - Ruby 1.8.6 support has officially been dropped. Not all tests pass. + - Raise sane error messages for broken config.ru + - Allow combining run and map in a config.ru + - Rack::ContentType will not set Content-Type for responses without a body + - Status code 205 does not send a response body + - Rack::Response::Helpers will not rely on instance variables + - Rack::Utils.build_query no longer outputs '=' for nil query values + - Various mime types added + - Rack::MockRequest now supports HEAD + - Rack::Directory now supports files that contain RFC3986 reserved chars + - Rack::File now only supports GET and HEAD requests + - Rack::Server#start now passes the block to Rack::Handler::<h>#run + - Rack::Static now supports an index option + - Added the Teapot status code + - rackup now defaults to Thin instead of Mongrel (if installed) + - Support added for HTTP_X_FORWARDED_SCHEME + - Numerous bug fixes, including many fixes for new and alternate rubies + +## [1.1.3] 2011-12-28 + - Security fix. http://www.ocert.org/advisories/ocert-2011-003.html + Further information here: http://jruby.org/2011/12/27/jruby-1-6-5-1 + +## [1.3.5] 2011-10-17 + - Fix annoying warnings caused by the backport in 1.3.4 + +## [1.3.4] 2011-10-01 + - Backport security fix from 1.9.3, also fixes some roundtrip issues in URI + - Small documentation update + - Fix an issue where BodyProxy could cause an infinite recursion + - Add some supporting files for travis-ci + +## [1.2.4] 2011-09-16 + - Fix a bug with MRI regex engine to prevent XSS by malformed unicode + +## [1.3.3] 2011-09-16 + - Fix bug with broken query parameters in Rack::ShowExceptions + - Rack::Request#cookies no longer swallows exceptions on broken input + - Prevents XSS attacks enabled by bug in Ruby 1.8's regexp engine + - Rack::ConditionalGet handles broken If-Modified-Since helpers + +## [1.3.2] 2011-07-16 + - Fix for Rails and rack-test, Rack::Utils#escape calls to_s + +## [1.3.1] 2011-07-13 + - Fix 1.9.1 support + - Fix JRuby support + - Properly handle $KCODE in Rack::Utils.escape + - Make method_missing/respond_to behavior consistent for Rack::Lock, + Rack::Auth::Digest::Request and Rack::Multipart::UploadedFile + - Reenable passing rack.session to session middleware + - Rack::CommonLogger handles streaming responses correctly + - Rack::MockResponse calls close on the body object + - Fix a DOS vector from MRI stdlib backport + +## [1.2.3] 2011-05-22 + - Pulled in relevant bug fixes from 1.3 + - Fixed 1.8.6 support + +## [1.3.0] 2011-05-22 + - Various performance optimizations + - Various multipart fixes + - Various multipart refactors + - Infinite loop fix for multipart + - Test coverage for Rack::Server returns + - Allow files with '..', but not path components that are '..' + - rackup accepts handler-specific options on the command line + - Request#params no longer merges POST into GET (but returns the same) + - Use URI.encode_www_form_component instead. Use core methods for escaping. + - Allow multi-line comments in the config file + - Bug L#94 reported by Nikolai Lugovoi, query parameter unescaping. + - Rack::Response now deletes Content-Length when appropriate + - Rack::Deflater now supports streaming + - Improved Rack::Handler loading and searching + - Support for the PATCH verb + - env['rack.session.options'] now contains session options + - Cookies respect renew + - Session middleware uses SecureRandom.hex + +## [1.2.2, 1.1.2] 2011-03-13 + - Security fix in Rack::Auth::Digest::MD5: when authenticator + returned nil, permission was granted on empty password. + +## [1.2.1] 2010-06-15 + - Make CGI handler rewindable + - Rename spec/ to test/ to not conflict with SPEC on lesser + operating systems + +## [1.2.0] 2010-06-13 + - Removed Camping adapter: Camping 2.0 supports Rack as-is + - Removed parsing of quoted values + - Add Request.trace? and Request.options? + - Add mime-type for .webm and .htc + - Fix HTTP_X_FORWARDED_FOR + - Various multipart fixes + - Switch test suite to bacon + +## [1.1.0] 2010-01-03 + - Moved Auth::OpenID to rack-contrib. + - SPEC change that relaxes Lint slightly to allow subclasses of the + required types + - SPEC change to document rack.input binary mode in greator detail + - SPEC define optional rack.logger specification + - File servers support X-Cascade header + - Imported Config middleware + - Imported ETag middleware + - Imported Runtime middleware + - Imported Sendfile middleware + - New Logger and NullLogger middlewares + - Added mime type for .ogv and .manifest. + - Don't squeeze PATH_INFO slashes + - Use Content-Type to determine POST params parsing + - Update Rack::Utils::HTTP_STATUS_CODES hash + - Add status code lookup utility + - Response should call #to_i on the status + - Add Request#user_agent + - Request#host knows about forwarded host + - Return an empty string for Request#host if HTTP_HOST and + SERVER_NAME are both missing + - Allow MockRequest to accept hash params + - Optimizations to HeaderHash + - Refactored rackup into Rack::Server + - Added Utils.build_nested_query to complement Utils.parse_nested_query + - Added Utils::Multipart.build_multipart to complement + Utils::Multipart.parse_multipart + - Extracted set and delete cookie helpers into Utils so they can be + used outside Response + - Extract parse_query and parse_multipart in Request so subclasses + can change their behavior + - Enforce binary encoding in RewindableInput + - Set correct external_encoding for handlers that don't use RewindableInput + +## [1.0.1] 2009-10-18 + - Bump remainder of rack.versions. + - Support the pure Ruby FCGI implementation. + - Fix for form names containing "=": split first then unescape components + - Fixes the handling of the filename parameter with semicolons in names. + - Add anchor to nested params parsing regexp to prevent stack overflows + - Use more compatible gzip write api instead of "<<". + - Make sure that Reloader doesn't break when executed via ruby -e + - Make sure WEBrick respects the :Host option + - Many Ruby 1.9 fixes. + +## [1.0.0] 2009-04-25 + - SPEC change: Rack::VERSION has been pushed to [1,0]. + - SPEC change: header values must be Strings now, split on "\n". + - SPEC change: Content-Length can be missing, in this case chunked transfer + encoding is used. + - SPEC change: rack.input must be rewindable and support reading into + a buffer, wrap with Rack::RewindableInput if it isn't. + - SPEC change: rack.session is now specified. + - SPEC change: Bodies can now additionally respond to #to_path with + a filename to be served. + - NOTE: String bodies break in 1.9, use an Array consisting of a + single String instead. + - New middleware Rack::Lock. + - New middleware Rack::ContentType. + - Rack::Reloader has been rewritten. + - Major update to Rack::Auth::OpenID. + - Support for nested parameter parsing in Rack::Response. + - Support for redirects in Rack::Response. + - HttpOnly cookie support in Rack::Response. + - The Rakefile has been rewritten. + - Many bugfixes and small improvements. + +## [0.9.1] 2009-01-09 + - Fix directory traversal exploits in Rack::File and Rack::Directory. + +## [0.9] 2009-01-06 + - Rack is now managed by the Rack Core Team. + - Rack::Lint is stricter and follows the HTTP RFCs more closely. + - Added ConditionalGet middleware. + - Added ContentLength middleware. + - Added Deflater middleware. + - Added Head middleware. + - Added MethodOverride middleware. + - Rack::Mime now provides popular MIME-types and their extension. + - Mongrel Header now streams. + - Added Thin handler. + - Official support for swiftiplied Mongrel. + - Secure cookies. + - Made HeaderHash case-preserving. + - Many bugfixes and small improvements. + +## [0.4] 2008-08-21 + - New middleware, Rack::Deflater, by Christoffer Sawicki. + - OpenID authentication now needs ruby-openid 2. + - New Memcache sessions, by blink. + - Explicit EventedMongrel handler, by Joshua Peek <josh@joshpeek.com> + - Rack::Reloader is not loaded in rackup development mode. + - rackup can daemonize with -D. + - Many bugfixes, especially for pool sessions, URLMap, thread safety + and tempfile handling. + - Improved tests. + - Rack moved to Git. + +## [0.3] 2008-02-26 + - LiteSpeed handler, by Adrian Madrid. + - SCGI handler, by Jeremy Evans. + - Pool sessions, by blink. + - OpenID authentication, by blink. + - :Port and :File options for opening FastCGI sockets, by blink. + - Last-Modified HTTP header for Rack::File, by blink. + - Rack::Builder#use now accepts blocks, by Corey Jewett. + (See example/protectedlobster.ru) + - HTTP status 201 can contain a Content-Type and a body now. + - Many bugfixes, especially related to Cookie handling. + +## [0.2] 2007-05-16 + - HTTP Basic authentication. + - Cookie Sessions. + - Static file handler. + - Improved Rack::Request. + - Improved Rack::Response. + - Added Rack::ShowStatus, for better default error messages. + - Bug fixes in the Camping adapter. + - Removed Rails adapter, was too alpha. + +## [0.1] 2007-03-03 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000000000000000000000000000000000..70a27468e5f8ad03c5e7c036cad139c06c2a70b8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,136 @@ +Contributing to Rack +===================== + +Rack is work of [hundreds of contributors](https://github.com/rack/rack/graphs/contributors). You're encouraged to submit [pull requests](https://github.com/rack/rack/pulls), [propose features and discuss issues](https://github.com/rack/rack/issues). When in doubt, post to the [rack-devel](http://groups.google.com/group/rack-devel) mailing list. + +#### Fork the Project + +Fork the [project on Github](https://github.com/rack/rack) and check out your copy. + +``` +git clone https://github.com/contributor/rack.git +cd rack +git remote add upstream https://github.com/rack/rack.git +``` + +#### Create a Topic Branch + +Make sure your fork is up-to-date and create a topic branch for your feature or bug fix. + +``` +git checkout master +git pull upstream master +git checkout -b my-feature-branch +``` + +#### Bundle Install and Quick Test + +Ensure that you can build the project and run quick tests. + +``` +bundle install --without extra +bundle exec rake test +``` + +#### Running All Tests + +Install all dependencies. + +``` +bundle install +``` + +Run all tests. + +``` +rake test +``` + +The test suite has no dependencies outside of the core Ruby installation and bacon. + +Some tests will be skipped if a dependency is not found. + +To run the test suite completely, you need: + + * fcgi + * dalli + * thin + +To test Memcache sessions, you need memcached (will be run on port 11211) and dalli installed. + +#### Write Tests + +Try to write a test that reproduces the problem you're trying to fix or describes a feature that you want to build. + +We definitely appreciate pull requests that highlight or reproduce a problem, even without a fix. + +#### Write Code + +Implement your feature or bug fix. + +Make sure that `bundle exec rake fulltest` completes without errors. + +#### Write Documentation + +Document any external behavior in the [README](README.rdoc). + +#### Update Changelog + +Add a line to [CHANGELOG](CHANGELOG.md). + +#### Commit Changes + +Make sure git knows your name and email address: + +``` +git config --global user.name "Your Name" +git config --global user.email "contributor@example.com" +``` + +Writing good commit logs is important. A commit log should describe what changed and why. + +``` +git add ... +git commit +``` + +#### Push + +``` +git push origin my-feature-branch +``` + +#### Make a Pull Request + +Go to https://github.com/contributor/rack and select your feature branch. Click the 'Pull Request' button and fill out the form. Pull requests are usually reviewed within a few days. + +#### Rebase + +If you've been working on a change for a while, rebase with upstream/master. + +``` +git fetch upstream +git rebase upstream/master +git push origin my-feature-branch -f +``` + +#### Make Required Changes + +Amend your previous commit and force push the changes. + +``` +git commit --amend +git push origin my-feature-branch -f +``` + +#### Check on Your Pull Request + +Go back to your pull request after a few minutes and see whether it passed muster with Travis-CI. Everything should look green, otherwise fix issues and amend your commit as described above. + +#### Be Patient + +It's likely that your change will not be merged and that the nitpicky maintainers will ask you to do more, or fix seemingly benign problems. Hang on there! + +#### Thank You + +Please do know that we really appreciate and value your time and work. We love you, really. diff --git a/Gemfile b/Gemfile index 62a3494e88155ae9d7f6e617e6d3bed148fa8683..b6ce15e4b96a12cbb8b0424361af42a2ff55b7eb 100644 --- a/Gemfile +++ b/Gemfile @@ -7,12 +7,17 @@ gemspec # What we need to do here is just *exclude* JRuby, but bundler has no way to do # this, because of some argument that I know I had with Yehuda and Carl years # ago, but I've since forgotten. Anyway, we actually need it here, and it's not -# avaialable, so prepare yourself for a yak shave when this breaks. +# available, so prepare yourself for a yak shave when this breaks. c_platforms = Bundler::Dsl::VALID_PLATFORMS.dup.delete_if do |platform| platform =~ /jruby/ end -gem "rubocop", "0.68.1", require: false +gem "rubocop", require: false + +group :test do + gem "webrick" # gemified in Ruby 3.1+ + gem "psych" +end # Alternative solution that might work, but it has bad interactions with # Gemfile.lock if that gets committed/reused: diff --git a/README.rdoc b/README.rdoc index 5d4ad7c4e394bc1c236014c7807d7c68553353ce..cbb257239a3cb236a148034a2044a457ee7b0055 100644 --- a/README.rdoc +++ b/README.rdoc @@ -5,6 +5,7 @@ {<img src="https://circleci.com/gh/rack/rack.svg?style=svg" alt="CircleCI" />}[https://circleci.com/gh/rack/rack] {<img src="https://badge.fury.io/rb/rack.svg" alt="Gem Version" />}[http://badge.fury.io/rb/rack] {<img src="https://api.dependabot.com/badges/compatibility_score?dependency-name=rack&package-manager=bundler&version-scheme=semver" alt="SemVer Stability" />}[https://dependabot.com/compatibility-score.html?dependency-name=rack&package-manager=bundler&version-scheme=semver] +{<img src="http://inch-ci.org/github/rack/rack.svg?branch=master" alt="Inline docs" />}[http://inch-ci.org/github/rack/rack] \Rack provides a minimal, modular, and adaptable interface for developing web applications in Ruby. By wrapping HTTP requests and responses in @@ -30,10 +31,11 @@ These web servers include \Rack handlers in their distributions: * Agoo[https://github.com/ohler55/agoo] * Falcon[https://github.com/socketry/falcon] +* Iodine[https://github.com/boazsegev/iodine] * {NGINX Unit}[https://unit.nginx.org/] * {Phusion Passenger}[https://www.phusionpassenger.com/] (which is mod_rack for Apache and for nginx) * Puma[https://puma.io/] -* Unicorn[https://bogomips.org/unicorn/] +* Unicorn[https://yhbt.net/unicorn/] * uWSGI[https://uwsgi-docs.readthedocs.io/en/latest/] Any valid \Rack app will run the same on all these handlers, without @@ -41,13 +43,12 @@ changing anything. == Supported web frameworks -These frameworks include \Rack adapters in their distributions: +These frameworks and many others support the \Rack API: * Camping[http://www.ruby-camping.com/] * Coset[http://leahneukirchen.org/repos/coset/] * Hanami[https://hanamirb.org/] * Padrino[http://padrinorb.com/] -* Racktools::SimpleApplication * Ramaze[http://ramaze.net/] * Roda[https://github.com/jeremyevans/roda] * {Ruby on Rails}[https://rubyonrails.org/] @@ -55,19 +56,45 @@ These frameworks include \Rack adapters in their distributions: * Sinatra[http://sinatrarb.com/] * Utopia[https://github.com/socketry/utopia] * WABuR[https://github.com/ohler55/wabur] -* ... and many others. -== Available middleware +== Available middleware shipped with \Rack Between the server and the framework, \Rack can be customized to your -applications needs using middleware, for example: +applications needs using middleware. \Rack itself ships with the following +middleware: -* Rack::URLMap, to route to multiple applications inside the same process. +* Rack::Chunked, for streaming responses using chunked encoding. * Rack::CommonLogger, for creating Apache-style logfiles. +* Rack::ConditionalGet, for returning not modified responses when the response + has not changed. +* Rack::Config, for modifying the environment before processing the request. +* Rack::ContentLength, for setting Content-Length header based on body size. +* Rack::ContentType, for setting default Content-Type header for responses. +* Rack::Deflater, for compressing responses with gzip. +* Rack::ETag, for setting ETag header on string bodies. +* Rack::Events, for providing easy hooks when a request is received + and when the response is sent. +* Rack::Files, for serving static files. +* Rack::Head, for returning an empty body for HEAD requests. +* Rack::Lint, for checking conformance to the \Rack API. +* Rack::Lock, for serializing requests using a mutex. +* Rack::Logger, for setting a logger to handle logging errors. +* Rack::MethodOverride, for modifying the request method based on a submitted + parameter. +* Rack::Recursive, for including data from other paths in the application, + and for performing internal redirects. +* Rack::Reloader, for reloading files if they have been modified. +* Rack::Runtime, for including a response header with the time taken to + process the request. +* Rack::Sendfile, for working with web servers that can use optimized + file serving for file system paths. * Rack::ShowException, for catching unhandled exceptions and presenting them in a nice and helpful way with clickable backtrace. -* Rack::Files, for serving static files. -* ...many others! +* Rack::ShowStatus, for using nice error pages for empty client error + responses. +* Rack::Static, for more configurable serving of static files. +* Rack::TempfileReaper, for removing temporary files creating during a + request. All these components use the same interface, which is described in detail in the \Rack specification. These optional components can be @@ -86,6 +113,15 @@ over: cookie handling. * Rack::MockRequest and Rack::MockResponse for efficient and quick testing of \Rack application without real HTTP round-trips. +* Rack::Cascade, for trying additional \Rack applications if an + application returns a not found or method not supported response. +* Rack::Directory, for serving files under a given directory, with + directory indexes. +* Rack::MediaType, for parsing Content-Type headers. +* Rack::Mime, for determining Content-Type based on file extension. +* Rack::RewindableInput, for making any IO object rewindable, using + a temporary file buffer. +* Rack::URLMap, to route to multiple applications inside the same process. == rack-contrib @@ -125,62 +161,80 @@ A Gem of \Rack is available at {rubygems.org}[https://rubygems.org/gems/rack]. Y gem install rack -== Running the tests +== Usage -Testing \Rack requires the bacon testing framework: +You should require the library: - bundle install --without extra # to be able to run the fast tests + require 'rack' -Or: +\Rack uses autoload to automatically load other files \Rack ships with on demand, +so you should not need require paths under +rack+. If you require paths under ++rack+ without requiring +rack+ itself, things may not work correctly. - bundle install # this assumes that you have installed native extensions! +== Configuration -There is a rake-based test task: +Several parameters can be modified on Rack::Utils to configure \Rack behaviour. - rake test # tests all the tests +e.g: -The testsuite has no dependencies outside of the core Ruby -installation and bacon. + Rack::Utils.key_space_limit = 128 -To run the test suite completely, you need: +=== key_space_limit - * fcgi - * dalli - * thin +The default number of bytes to allow all parameters keys in a given parameter hash to take up. +Does not affect nested parameter hashes, so doesn't actually prevent an attacker from using +more than this many bytes for parameter keys. -To test Memcache sessions, you need memcached (will be -run on port 11211) and dalli installed. +Defaults to 65536 characters. -== Configuration +=== param_depth_limit -Several parameters can be modified on Rack::Utils to configure \Rack behaviour. +The maximum amount of nesting allowed in parameters. +For example, if set to 3, this query string would be allowed: -e.g: + ?a[b][c]=d - Rack::Utils.key_space_limit = 128 +but this query string would not be allowed: -=== key_space_limit + ?a[b][c][d]=e -The default number of bytes to allow a single parameter key to take up. -This helps prevent a rogue client from flooding a Request. +Limiting the depth prevents a possible stack overflow when parsing parameters. -Default to 65536 characters (4 kiB in worst case). +Defaults to 100. -=== multipart_part_limit +=== multipart_file_limit -The maximum number of parts a request can contain. +The maximum number of parts with a filename a request can contain. Accepting too many part can lead to the server running out of file handles. The default is 128, which means that a single request can't upload more than 128 files at once. Set to 0 for no limit. -Can also be set via the +RACK_MULTIPART_PART_LIMIT+ environment variable. +Can also be set via the +RACK_MULTIPART_FILE_LIMIT+ environment variable. + +(This is also aliased as +multipart_part_limit+ and +RACK_MULTIPART_PART_LIMIT+ for compatibility) + +=== multipart_total_part_limit + +The maximum total number of parts a request can contain of any type, including +both file and non-file form fields. + +The default is 4096, which means that a single request can't contain more than +4096 parts. + +Set to 0 for no limit. + +Can also be set via the +RACK_MULTIPART_TOTAL_PART_LIMIT+ environment variable. == Changelog See {CHANGELOG.md}[https://github.com/rack/rack/blob/master/CHANGELOG.md]. +== Contributing + +See {CONTRIBUTING.md}[https://github.com/rack/rack/blob/master/CONTRIBUTING.md]. + == Contact Please post bugs, suggestions and patches to @@ -198,7 +252,6 @@ Mailing list archives are available at Git repository (send Git patches to the mailing list): * https://github.com/rack/rack -* http://git.vuxu.org/cgi-bin/gitweb.cgi?p=rack-github.git You are also welcome to join the #rack channel on irc.freenode.net. @@ -206,20 +259,25 @@ You are also welcome to join the #rack channel on irc.freenode.net. The \Rack Core Team, consisting of +* Aaron Patterson (tenderlove[https://github.com/tenderlove]) +* Samuel Williams (ioquatix[https://github.com/ioquatix]) +* Jeremy Evans (jeremyevans[https://github.com/jeremyevans]) +* Eileen Uchitelle (eileencodes[https://github.com/eileencodes]) +* Matthew Draper (matthewd[https://github.com/matthewd]) +* Rafael França (rafaelfranca[https://github.com/rafaelfranca]) + +and the \Rack Alumni + +* Ryan Tomayko (rtomayko[https://github.com/rtomayko]) +* Scytrin dai Kinthra (scytrin[https://github.com/scytrin]) * Leah Neukirchen (leahneukirchen[https://github.com/leahneukirchen]) * James Tucker (raggi[https://github.com/raggi]) * Josh Peek (josh[https://github.com/josh]) * José Valim (josevalim[https://github.com/josevalim]) * Michael Fellinger (manveru[https://github.com/manveru]) -* Aaron Patterson (tenderlove[https://github.com/tenderlove]) * Santiago Pastorino (spastorino[https://github.com/spastorino]) * Konstantin Haase (rkh[https://github.com/rkh]) -and the \Rack Alumnis - -* Ryan Tomayko (rtomayko[https://github.com/rtomayko]) -* Scytrin dai Kinthra (scytrin[https://github.com/scytrin]) - would like to thank: * Adrian Madrid, for the LiteSpeed handler. diff --git a/Rakefile b/Rakefile index a365ff5420657f585f5ca8792f7fd0dc9f450a1f..237c3f26160f60d13c2b462c3e61db4c147e8fa5 100644 --- a/Rakefile +++ b/Rakefile @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "bundler/gem_tasks" require "rake/testtask" desc "Run all the tests" @@ -20,7 +21,7 @@ end desc "Make an archive as .tar.gz" task dist: %w[chmod changelog spec rdoc] do sh "git archive --format=tar --prefix=#{release}/ HEAD^{tree} >#{release}.tar" - sh "pax -waf #{release}.tar -s ':^:#{release}/:' SPEC ChangeLog doc rack.gemspec" + sh "pax -waf #{release}.tar -s ':^:#{release}/:' SPEC.rdoc ChangeLog doc rack.gemspec" sh "gzip -f -9 #{release}.tar" end @@ -38,7 +39,7 @@ task officialrelease_really: %w[spec dist gem] do end def release - "rack-" + File.read('lib/rack.rb')[/RELEASE += +([\"\'])([\d][\w\.]+)\1/, 2] + "rack-" + File.read('lib/rack/version.rb')[/RELEASE += +([\"\'])([\d][\w\.]+)\1/, 2] end desc "Make binaries executable" @@ -71,13 +72,13 @@ file "ChangeLog" => '.git/index' do end desc "Generate Rack Specification" -task spec: "SPEC" +task spec: "SPEC.rdoc" file 'lib/rack/lint.rb' -file "SPEC" => 'lib/rack/lint.rb' do - File.open("SPEC", "wb") { |file| +file "SPEC.rdoc" => 'lib/rack/lint.rb' do + File.open("SPEC.rdoc", "wb") { |file| IO.foreach("lib/rack/lint.rb") { |line| - if line =~ /## (.*)/ + if line =~ /^\s*## ?(.*)/ file.puts $1 end } @@ -91,6 +92,12 @@ Rake::TestTask.new("test:regular") do |t| t.verbose = true end +desc "Run tests with coverage" +task "test_cov" do + ENV['COVERAGE'] = '1' + Rake::Task['test:regular'].invoke +end + desc "Run all the fast + platform agnostic tests" task test: %w[spec test:regular] @@ -107,7 +114,7 @@ desc "Generate RDoc documentation" task rdoc: %w[changelog spec] do sh(*%w{rdoc --line-numbers --main README.rdoc --title 'Rack\ Documentation' --charset utf-8 -U -o doc} + - %w{README.rdoc KNOWN-ISSUES SPEC ChangeLog} + + %w{README.rdoc KNOWN-ISSUES SPEC.rdoc ChangeLog} + `git ls-files lib/\*\*/\*.rb`.strip.split) cp "contrib/rdoc.css", "doc/rdoc.css" end diff --git a/SECURITY_POLICY.md b/SECURITY_POLICY.md index 04fdd48839864f39c4735074875af33ff69524ea..3590fa4d508272dae33b028f8b3c81396bf82092 100644 --- a/SECURITY_POLICY.md +++ b/SECURITY_POLICY.md @@ -10,26 +10,26 @@ New features will only be added to the master branch and will not be made availa Only the latest release series will receive bug fixes. When enough bugs are fixed and its deemed worthy to release a new gem, this is the branch it happens from. -* Current release series: 2.0.x +* Current release series: 2.1.x ### Security issues The current release series and the next most recent one will receive patches and new versions in case of a security issue. -* Current release series: 2.0.x -* Next most recent release series: 1.6.x +* Current release series: 2.1.x +* Next most recent release series: 2.0.x ### Severe security issues For severe security issues we will provide new versions as above, and also the last major release series will receive patches and new versions. The classification of the security issue is judged by the core team. -* Current release series: 2.0.x -* Next most recent release series: 1.6.x -* Last most recent release series: 1.5.x +* Current release series: 2.1.x +* Next most recent release series: 2.0.x +* Last major release series: 1.6.x ### Unsupported Release Series -When a release series is no longer supported, it’s your own responsibility to deal with bugs and security issues. We may provide back-ports of the fixes and publish them to git, however there will be no new versions released. If you are not comfortable maintaining your own versions, you should upgrade to a supported version. +When a release series is no longer supported, it’s your own responsibility to deal with bugs and security issues. If you are not comfortable maintaining your own versions, you should upgrade to a supported version. ## Reporting a bug diff --git a/SPEC b/SPEC.rdoc similarity index 98% rename from SPEC rename to SPEC.rdoc index 59ec9d7202608bec9fe9a7e136c44aaf2a151253..277142376e1e24b1fc3f79accc41dcf815ae33a5 100644 --- a/SPEC +++ b/SPEC.rdoc @@ -1,5 +1,6 @@ This specification aims to formalize the Rack protocol. You can (and should) use Rack::Lint to enforce it. + When you develop middleware, be sure to add a Lint before and after to catch all mistakes. = Rack applications @@ -11,9 +12,10 @@ The *status*, the *headers*, and the *body*. == The Environment -The environment must be an instance of Hash that includes +The environment must be an unfrozen instance of Hash that includes CGI-like headers. The application is free to modify the environment. + The environment is required to include these variables (adopted from PEP333), except when they'd be empty, but see below. @@ -104,6 +106,7 @@ be implemented by the server. fetch(key, default = nil) (aliased as []); delete(key); clear; + to_hash (returning unfrozen Hash instance); <tt>rack.logger</tt>:: A common object interface for logging messages. The object must implement: info(message, &block) @@ -118,10 +121,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. + 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>). The CGI keys (named without a period) must have String values. +If the string values for CGI keys contain non-ASCII characters, +they should use ASCII-8BIT encoding. There are the following restrictions: * <tt>rack.version</tt> must be an array of Integers. * <tt>rack.url_scheme</tt> must either be +http+ or +https+. @@ -137,6 +143,7 @@ There are the following restrictions: <tt>SCRIPT_NAME</tt> is empty. <tt>SCRIPT_NAME</tt> never should be <tt>/</tt>, but instead be empty. === The Input Stream + The input stream is an IO-like object which contains the raw HTTP POST data. When applicable, its external encoding must be "ASCII-8BIT" and it @@ -146,14 +153,19 @@ The input stream must respond to +gets+, +each+, +read+ and +rewind+. 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. * +each+ must be called without arguments and only yield Strings. @@ -175,16 +187,20 @@ The error stream must respond to +puts+, +write+ and +flush+. If rack.hijack? is true then rack.hijack must respond to #call. rack.hijack must return the io that will also be assigned (or is already present, in rack.hijack_io. + rack.hijack_io must respond to: <tt>read, write, read_nonblock, write_nonblock, flush, close, close_read, close_write, closed?</tt> + The semantics of these IO methods must be a best effort match to those of a normal ruby IO or Socket object, using standard arguments and raising standard exceptions. Servers are encouraged to simply pass on real IO objects, although it is recognized that this approach is not directly compatible with SPDY and HTTP 2.0. + IO provided in rack.hijack_io should preference the IO::WaitReadable and IO::WaitWritable APIs wherever supported. + There is a deliberate lack of full specification around rack.hijack_io, as semantics will change from server to server. Users are encouraged to utilize this API with a knowledge of their @@ -192,7 +208,9 @@ server choice, and servers may extend the functionality of hijack_io to provide additional features to users. The purpose of rack.hijack is for Rack to "get out of the way", as such, Rack only provides the minimum of specification and support. + If rack.hijack? is false, then rack.hijack should not be set. + If rack.hijack? is false, then rack.hijack_io should not be set. ==== Response (after headers) It is also possible to hijack a response after the status and headers @@ -201,6 +219,7 @@ In order to do this, an application may set the special header <tt>rack.hijack</tt> to an object that responds to <tt>call</tt> accepting an argument that conforms to the <tt>rack.hijack_io</tt> protocol. + After the headers have been sent, and this hijack callback has been called, the application is now responsible for the remaining lifecycle of the IO. The application is also responsible for maintaining HTTP @@ -209,8 +228,10 @@ applications will have wanted to specify the header Connection:close in HTTP/1.1, and not Connection:keep-alive, as there is no protocol for returning hijacked sockets to the web server. For that purpose, use the body streaming API instead (progressively yielding strings via each). + Servers must ignore the <tt>body</tt> part of the response tuple when the <tt>rack.hijack</tt> response API is in use. + The special response header <tt>rack.hijack</tt> must only be set if the request env has <tt>rack.hijack?</tt> <tt>true</tt>. ==== Conventions @@ -244,16 +265,20 @@ There must not be a <tt>Content-Length</tt> header when the === The Body The Body must respond to +each+ and must only yield String values. + The Body itself should not be an instance of String, as this will break in Ruby 1.9. + If the Body responds to +close+, it will be called after iteration. If the body is replaced by a middleware after action, the original body must be closed first, if it responds to close. + If the Body responds to +to_path+, it must return a String identifying the location of a file whose contents are identical to that produced by calling +each+; this may be used by the server as an alternative, possibly more efficient way to transport the response. + The Body commonly is an Array of Strings, the application instance itself, or a File-like object. == Thanks diff --git a/lib/rack.rb b/lib/rack.rb index 634235ae318eecd1ba05f03e29b1cdb7378c3df4..e4494e5bac40721ac9bd64aee047bc1c304f563b 100644 --- a/lib/rack.rb +++ b/lib/rack.rb @@ -11,23 +11,11 @@ # All modules meant for use in your application are <tt>autoload</tt>ed here, # so it should be enough just to <tt>require 'rack'</tt> in your code. -module Rack - # The Rack protocol version number implemented. - VERSION = [1, 3] - - # Return the Rack protocol version as a dotted string. - def self.version - VERSION.join(".") - end - - RELEASE = "2.1.4" - - # Return the Rack release as a dotted string. - def self.release - RELEASE - end +require_relative 'rack/version' +module Rack HTTP_HOST = 'HTTP_HOST' + HTTP_PORT = 'HTTP_PORT' HTTP_VERSION = 'HTTP_VERSION' HTTPS = 'HTTPS' PATH_INFO = 'PATH_INFO' @@ -37,9 +25,9 @@ module Rack QUERY_STRING = 'QUERY_STRING' SERVER_PROTOCOL = 'SERVER_PROTOCOL' SERVER_NAME = 'SERVER_NAME' - SERVER_ADDR = 'SERVER_ADDR' SERVER_PORT = 'SERVER_PORT' CACHE_CONTROL = 'Cache-Control' + EXPIRES = 'Expires' CONTENT_LENGTH = 'Content-Length' CONTENT_TYPE = 'Content-Type' SET_COOKIE = 'Set-Cookie' @@ -98,6 +86,7 @@ module Rack autoload :ContentLength, "rack/content_length" autoload :ContentType, "rack/content_type" autoload :ETag, "rack/etag" + autoload :Events, "rack/events" autoload :File, "rack/file" autoload :Files, "rack/files" autoload :Deflater, "rack/deflater" @@ -108,11 +97,13 @@ module Rack autoload :Lint, "rack/lint" autoload :Lock, "rack/lock" autoload :Logger, "rack/logger" + autoload :MediaType, "rack/media_type" autoload :MethodOverride, "rack/method_override" autoload :Mime, "rack/mime" autoload :NullLogger, "rack/null_logger" autoload :Recursive, "rack/recursive" autoload :Reloader, "rack/reloader" + autoload :RewindableInput, "rack/rewindable_input" autoload :Runtime, "rack/runtime" autoload :Sendfile, "rack/sendfile" autoload :Server, "rack/server" diff --git a/lib/rack/auth/abstract/request.rb b/lib/rack/auth/abstract/request.rb index 23da4bf2717c0056fba03b10a9b7496f2678d507..34042c401b7b89a38734632134bef3459185f342 100644 --- a/lib/rack/auth/abstract/request.rb +++ b/lib/rack/auth/abstract/request.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'rack/request' - module Rack module Auth class AbstractRequest diff --git a/lib/rack/auth/basic.rb b/lib/rack/auth/basic.rb index d334939ca2b8a75fe438590a31286bcf730e8065..d5b4ea16da3d9ceef01bdd499062b7c0d3178a2f 100644 --- a/lib/rack/auth/basic.rb +++ b/lib/rack/auth/basic.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require 'rack/auth/abstract/handler' -require 'rack/auth/abstract/request' +require_relative 'abstract/handler' +require_relative 'abstract/request' require 'base64' module Rack @@ -44,7 +44,7 @@ module Rack class Request < Auth::AbstractRequest def basic? - "basic" == scheme + "basic" == scheme && credentials.length == 2 end def credentials diff --git a/lib/rack/auth/digest/md5.rb b/lib/rack/auth/digest/md5.rb index 62bff9846ae6babbbb34a40c215f2459fafe21f1..04b103e2583fc41157a2720b6b04ed8c1643df5f 100644 --- a/lib/rack/auth/digest/md5.rb +++ b/lib/rack/auth/digest/md5.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -require 'rack/auth/abstract/handler' -require 'rack/auth/digest/request' -require 'rack/auth/digest/params' -require 'rack/auth/digest/nonce' +require_relative '../abstract/handler' +require_relative 'request' +require_relative 'params' +require_relative 'nonce' require 'digest/md5' module Rack diff --git a/lib/rack/auth/digest/request.rb b/lib/rack/auth/digest/request.rb index a3ab47439b74d8ac0ad1ed9fcbf94e1783b897a1..7b89b7605227294788c340ac63e8a3fab7a28229 100644 --- a/lib/rack/auth/digest/request.rb +++ b/lib/rack/auth/digest/request.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require 'rack/auth/abstract/request' -require 'rack/auth/digest/params' -require 'rack/auth/digest/nonce' +require_relative '../abstract/request' +require_relative 'params' +require_relative 'nonce' module Rack module Auth diff --git a/lib/rack/body_proxy.rb b/lib/rack/body_proxy.rb index cb161980ed9420a90fdade7359f64224d8a5fa73..cfc0796a612e0c980ab58e809cbee93509430c9b 100644 --- a/lib/rack/body_proxy.rb +++ b/lib/rack/body_proxy.rb @@ -1,17 +1,25 @@ # frozen_string_literal: true module Rack + # Proxy for response bodies allowing calling a block when + # the response body is closed (after the response has been fully + # sent to the client). class BodyProxy + # Set the response body to wrap, and the block to call when the + # response has been fully sent. def initialize(body, &block) @body = body @block = block @closed = false end - def respond_to?(method_name, include_all = false) + # Return whether the wrapped body responds to the method. + def respond_to_missing?(method_name, include_all = false) super or @body.respond_to?(method_name, include_all) end + # If not already closed, close the wrapped body and + # then call the block the proxy was initialized with. def close return if @closed @closed = true @@ -22,20 +30,16 @@ module Rack end end + # Whether the proxy is closed. The proxy starts as not closed, + # and becomes closed on the first call to close. def closed? @closed end - # N.B. This method is a special case to address the bug described by #434. - # We are applying this special case for #each only. Future bugs of this - # class will be handled by requesting users to patch their ruby - # implementation, to save adding too many methods in this class. - def each - @body.each { |body| yield body } - end - + # Delegate missing methods to the wrapped body. def method_missing(method_name, *args, &block) @body.__send__(method_name, *args, &block) end + ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true) end end diff --git a/lib/rack/builder.rb b/lib/rack/builder.rb index ebfa1f1184d3afe924248065f94d0d0b21d9738e..816ecf62085eff3518a0c7efd3a5122406777e4a 100644 --- a/lib/rack/builder.rb +++ b/lib/rack/builder.rb @@ -35,6 +35,32 @@ module Rack # https://stackoverflow.com/questions/2223882/whats-the-difference-between-utf-8-and-utf-8-without-bom UTF_8_BOM = '\xef\xbb\xbf' + # Parse the given config file to get a Rack application. + # + # If the config file ends in +.ru+, it is treated as a + # rackup file and the contents will be treated as if + # specified inside a Rack::Builder block, using the given + # options. + # + # If the config file does not end in +.ru+, it is + # required and Rack will use the basename of the file + # to guess which constant will be the Rack application to run. + # The options given will be ignored in this case. + # + # Examples: + # + # Rack::Builder.parse_file('config.ru') + # # Rack application built using Rack::Builder.new + # + # Rack::Builder.parse_file('app.rb') + # # requires app.rb, which can be anywhere in Ruby's + # # load path. After requiring, assumes App constant + # # contains Rack application + # + # Rack::Builder.parse_file('./my_app.rb') + # # requires ./my_app.rb, which should be in the + # # process's current directory. After requiring, + # # assumes MyApp constant contains Rack application def self.parse_file(config, opts = Server::Options.new) if config.end_with?('.ru') return self.load_file(config, opts) @@ -45,6 +71,25 @@ module Rack end end + # Load the given file as a rackup file, treating the + # contents as if specified inside a Rack::Builder block. + # + # Treats the first comment at the beginning of a line + # that starts with a backslash as options similar to + # options passed on a rackup command line. + # + # Ignores content in the file after +__END__+, so that + # use of +__END__+ will not result in a syntax error. + # + # Example config.ru file: + # + # $ cat config.ru + # + # #\ -p 9393 + # + # use Rack::ContentLength + # require './app.rb' + # run App def self.load_file(path, opts = Server::Options.new) options = {} @@ -52,6 +97,7 @@ module Rack cfgfile.slice!(/\A#{UTF_8_BOM}/) if cfgfile.encoding == Encoding::UTF_8 if cfgfile[/^#\\(.*)/] && opts + warn "Parsing options from the first comment line is deprecated!" options = opts.parse! $1.split(/\s+/) end @@ -61,16 +107,26 @@ module Rack return app, options end + # Evaluate the given +builder_script+ string in the context of + # a Rack::Builder block, returning a Rack application. def self.new_from_string(builder_script, file = "(rackup)") - eval "Rack::Builder.new {\n" + builder_script + "\n}.to_app", - TOPLEVEL_BINDING, file, 0 + # We want to build a variant of TOPLEVEL_BINDING with self as a Rack::Builder instance. + # We cannot use instance_eval(String) as that would resolve constants differently. + binding, builder = TOPLEVEL_BINDING.eval('Rack::Builder.new.instance_eval { [binding, self] }') + eval builder_script, binding, file + builder.to_app end + # Initialize a new Rack::Builder instance. +default_app+ specifies the + # default application if +run+ is not called later. If a block + # is given, it is evaluted in the context of the instance. def initialize(default_app = nil, &block) @use, @map, @run, @warmup, @freeze_app = [], nil, default_app, nil, false instance_eval(&block) if block_given? end + # Create a new Rack::Builder instance and return the Rack application + # generated from it. def self.app(default_app = nil, &block) self.new(default_app, &block).to_app end @@ -121,7 +177,8 @@ module Rack @run = app end - # Takes a lambda or block that is used to warm-up the application. + # Takes a lambda or block that is used to warm-up the application. This block is called + # before the Rack application is returned by to_app. # # warmup do |app| # client = Rack::MockRequest.new(app) @@ -134,25 +191,31 @@ module Rack @warmup = prc || block end - # Creates a route within the application. + # Creates a route within the application. Routes under the mapped path will be sent to + # the Rack application specified by run inside the block. Other requests will be sent to the + # default application specified by run outside the block. # # Rack::Builder.app do - # map '/' do + # map '/heartbeat' do # run Heartbeat # end + # run App # end # - # The +use+ method can also be used here to specify middleware to run under a specific path: + # The +use+ method can also be used inside the block to specify middleware to run under a specific path: # # Rack::Builder.app do - # map '/' do + # map '/heartbeat' do # use Middleware # run Heartbeat # end + # run App # end # - # This example includes a piece of middleware which will run before requests hit +Heartbeat+. + # This example includes a piece of middleware which will run before +/heartbeat+ requests hit +Heartbeat+. # + # Note that providing a +path+ of +/+ will ignore any default application given in a +run+ statement + # outside the block. def map(path, &block) @map ||= {} @map[path] = block @@ -164,6 +227,7 @@ module Rack @freeze_app = true end + # Return the Rack application generated by this instance. def to_app app = @map ? generate_map(@run, @map) : @run fail "missing run or map statement" unless app @@ -173,12 +237,17 @@ module Rack app end + # Call the Rack application generated by this builder instance. Note that + # this rebuilds the Rack application and runs the warmup code (if any) + # every time it is called, so it should not be used if performance is important. def call(env) to_app.call(env) end private + # Generate a URLMap instance by generating new Rack applications for each + # map block in this instance. def generate_map(default_app, mapping) mapped = default_app ? { '/' => default_app } : {} mapping.each { |r, b| mapped[r] = self.class.new(default_app, &b).to_app } diff --git a/lib/rack/cascade.rb b/lib/rack/cascade.rb index 1ed7ffa980330382a0c98e36464c845391d56169..d71274c2b7cca26d65a455e1635662f543053fc9 100644 --- a/lib/rack/cascade.rb +++ b/lib/rack/cascade.rb @@ -2,25 +2,37 @@ module Rack # Rack::Cascade tries a request on several apps, and returns the - # first response that is not 404 or 405 (or in a list of configurable - # status codes). + # first response that is not 404 or 405 (or in a list of configured + # status codes). If all applications tried return one of the configured + # status codes, return the last response. class Cascade + # deprecated, no longer used NotFound = [404, { CONTENT_TYPE => "text/plain" }, []] + # An array of applications to try in order. attr_reader :apps - def initialize(apps, catch = [404, 405]) + # Set the apps to send requests to, and what statuses result in + # cascading. Arguments: + # + # apps: An enumerable of rack applications. + # cascade_for: The statuses to use cascading for. If a response is received + # from an app, the next app is tried. + def initialize(apps, cascade_for = [404, 405]) @apps = [] apps.each { |app| add app } - @catch = {} - [*catch].each { |status| @catch[status] = true } + @cascade_for = {} + [*cascade_for].each { |status| @cascade_for[status] = true } end + # Call each app in order. If the responses uses a status that requires + # cascading, try the next app. If all responses require cascading, + # return the response from the last app. def call(env) - result = NotFound - + return [404, { CONTENT_TYPE => "text/plain" }, []] if @apps.empty? + result = nil last_body = nil @apps.each do |app| @@ -33,17 +45,20 @@ module Rack last_body.close if last_body.respond_to? :close result = app.call(env) + return result unless @cascade_for.include?(result[0].to_i) last_body = result[2] - break unless @catch.include?(result[0].to_i) end result end + # Append an app to the list of apps to cascade. This app will + # be tried last. def add(app) @apps << app end + # Whether the given app is one of the apps to cascade to. def include?(app) @apps.include?(app) end diff --git a/lib/rack/chunked.rb b/lib/rack/chunked.rb index e7e7d8d1db1d2f0ca717b685c199532c4d2a5712..84c6600140524118800b87918cc8fed8dcff33a0 100644 --- a/lib/rack/chunked.rb +++ b/lib/rack/chunked.rb @@ -1,53 +1,74 @@ # frozen_string_literal: true -require 'rack/utils' - module Rack # Middleware that applies chunked transfer encoding to response bodies # when the response does not include a Content-Length header. + # + # This supports the Trailer response header to allow the use of trailing + # headers in the chunked encoding. However, using this requires you manually + # specify a response body that supports a +trailers+ method. Example: + # + # [200, { 'Trailer' => 'Expires'}, ["Hello", "World"]] + # # error raised + # + # body = ["Hello", "World"] + # def body.trailers + # { 'Expires' => Time.now.to_s } + # end + # [200, { 'Trailer' => 'Expires'}, body] + # # No exception raised class Chunked include Rack::Utils - # A body wrapper that emits chunked responses + # A body wrapper that emits chunked responses. class Body TERM = "\r\n" TAIL = "0#{TERM}" - include Rack::Utils - + # Store the response body to be chunked. def initialize(body) @body = body end + # For each element yielded by the response body, yield + # the element in chunked encoding. def each(&block) term = TERM @body.each do |chunk| size = chunk.bytesize next if size == 0 - chunk = chunk.b - yield [size.to_s(16), term, chunk, term].join + yield [size.to_s(16), term, chunk.b, term].join end yield TAIL - insert_trailers(&block) - yield TERM + yield_trailers(&block) + yield term end + # Close the response body if the response body supports it. def close @body.close if @body.respond_to?(:close) end private - def insert_trailers(&block) + # Do nothing as this class does not support trailer headers. + def yield_trailers end end + # A body wrapper that emits chunked responses and also supports + # sending Trailer headers. Note that the response body provided to + # initialize must have a +trailers+ method that returns a hash + # of trailer headers, and the rack response itself should have a + # Trailer header listing the headers that the +trailers+ method + # will return. class TrailerBody < Body private - def insert_trailers(&block) + # Yield strings for each trailer header. + def yield_trailers @body.trailers.each_pair do |k, v| yield "#{k}: #{v}\r\n" end @@ -58,10 +79,11 @@ module Rack @app = app end - # pre-HTTP/1.0 (informally "HTTP/0.9") HTTP requests did not have - # a version (nor response headers) + # Whether the HTTP version supports chunked encoding (HTTP 1.1 does). def chunkable_version?(ver) case ver + # pre-HTTP/1.0 (informally "HTTP/0.9") HTTP requests did not have + # a version (nor response headers) when 'HTTP/1.0', nil, 'HTTP/0.9' false else @@ -69,24 +91,27 @@ module Rack end end + # If the rack app returns a response that should have a body, + # but does not have Content-Length or Transfer-Encoding headers, + # modify the response to use chunked Transfer-Encoding. def call(env) status, headers, body = @app.call(env) - headers = HeaderHash.new(headers) + headers = HeaderHash[headers] + + if chunkable_version?(env[SERVER_PROTOCOL]) && + !STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) && + !headers[CONTENT_LENGTH] && + !headers[TRANSFER_ENCODING] - if ! chunkable_version?(env[SERVER_PROTOCOL]) || - STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) || - headers[CONTENT_LENGTH] || - headers[TRANSFER_ENCODING] - [status, headers, body] - else - headers.delete(CONTENT_LENGTH) headers[TRANSFER_ENCODING] = 'chunked' if headers['Trailer'] - [status, headers, TrailerBody.new(body)] + body = TrailerBody.new(body) else - [status, headers, Body.new(body)] + body = Body.new(body) end end + + [status, headers, body] end end end diff --git a/lib/rack/common_logger.rb b/lib/rack/common_logger.rb index a513ff6ea5eebf9e8504380d552a9ec8722aacb5..9c6f92147d946ce5af73a819722e1819a3f774a5 100644 --- a/lib/rack/common_logger.rb +++ b/lib/rack/common_logger.rb @@ -1,45 +1,49 @@ # frozen_string_literal: true -require 'rack/body_proxy' - module Rack # Rack::CommonLogger forwards every request to the given +app+, and # logs a line in the # {Apache common log format}[http://httpd.apache.org/docs/1.3/logs.html#common] - # to the +logger+. - # - # If +logger+ is nil, CommonLogger will fall back +rack.errors+, which is - # an instance of Rack::NullLogger. - # - # +logger+ can be any class, including the standard library Logger, and is - # expected to have either +write+ or +<<+ method, which accepts the CommonLogger::FORMAT. - # According to the SPEC, the error stream must also respond to +puts+ - # (which takes a single argument that responds to +to_s+), and +flush+ - # (which is called without arguments in order to make the error appear for - # sure) + # to the configured logger. class CommonLogger # Common Log Format: http://httpd.apache.org/docs/1.3/logs.html#common # # lilith.local - - [07/Aug/2006 23:58:02 -0400] "GET / HTTP/1.1" 500 - # # %{%s - %s [%s] "%s %s%s %s" %d %s\n} % - FORMAT = %{%s - %s [%s] "%s %s%s %s" %d %s %0.4f\n} + # + # The actual format is slightly different than the above due to the + # separation of SCRIPT_NAME and PATH_INFO, and because the elapsed + # time in seconds is included at the end. + FORMAT = %{%s - %s [%s] "%s %s%s%s %s" %d %s %0.4f\n} + # +logger+ can be any object that supports the +write+ or +<<+ methods, + # which includes the standard library Logger. These methods are called + # with a single string argument, the log message. + # If +logger+ is nil, CommonLogger will fall back <tt>env['rack.errors']</tt>. def initialize(app, logger = nil) @app = app @logger = logger end + # Log all requests in common_log format after a response has been + # returned. Note that if the app raises an exception, the request + # will not be logged, so if exception handling middleware are used, + # they should be loaded after this middleware. Additionally, because + # the logging happens after the request body has been fully sent, any + # exceptions raised during the sending of the response body will + # cause the request not to be logged. def call(env) began_at = Utils.clock_time - status, header, body = @app.call(env) - header = Utils::HeaderHash.new(header) - body = BodyProxy.new(body) { log(env, status, header, began_at) } - [status, header, body] + status, headers, body = @app.call(env) + headers = Utils::HeaderHash[headers] + body = BodyProxy.new(body) { log(env, status, headers, began_at) } + [status, headers, body] end private + # Log the request to the configured logger. def log(env, status, header, began_at) length = extract_content_length(header) @@ -48,6 +52,7 @@ module Rack env["REMOTE_USER"] || "-", Time.now.strftime("%d/%b/%Y:%H:%M:%S %z"), env[REQUEST_METHOD], + env[SCRIPT_NAME], env[PATH_INFO], env[QUERY_STRING].empty? ? "" : "?#{env[QUERY_STRING]}", env[SERVER_PROTOCOL], @@ -55,7 +60,10 @@ module Rack length, Utils.clock_time - began_at ] + msg.gsub!(/[^[:print:]\n]/) { |c| "\\x#{c.ord}" } + logger = @logger || env[RACK_ERRORS] + # Standard library logger doesn't support write but it supports << which actually # calls to write on the log device without formatting if logger.respond_to?(:write) @@ -65,6 +73,8 @@ module Rack end end + # Attempt to determine the content length for the response to + # include it in the logged data. def extract_content_length(headers) value = headers[CONTENT_LENGTH] !value || value.to_s == '0' ? '-' : value diff --git a/lib/rack/conditional_get.rb b/lib/rack/conditional_get.rb index bda8daf6dcf0910d0070dc78d958d366e202e307..7b7808ac1f77095264c90650d8878de1d736e9c0 100644 --- a/lib/rack/conditional_get.rb +++ b/lib/rack/conditional_get.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'rack/utils' - module Rack # Middleware that enables conditional GET using If-None-Match and @@ -21,11 +19,13 @@ module Rack @app = app end + # Return empty 304 response if the response has not been + # modified since the last request. def call(env) case env[REQUEST_METHOD] when "GET", "HEAD" status, headers, body = @app.call(env) - headers = Utils::HeaderHash.new(headers) + headers = Utils::HeaderHash[headers] if status == 200 && fresh?(env, headers) status = 304 headers.delete(CONTENT_TYPE) @@ -43,28 +43,32 @@ module Rack private + # Return whether the response has not been modified since the + # last request. def fresh?(env, headers) - modified_since = env['HTTP_IF_MODIFIED_SINCE'] - none_match = env['HTTP_IF_NONE_MATCH'] - - return false unless modified_since || none_match - - success = true - success &&= modified_since?(to_rfc2822(modified_since), headers) if modified_since - success &&= etag_matches?(none_match, headers) if none_match - success + # If-None-Match has priority over If-Modified-Since per RFC 7232 + if none_match = env['HTTP_IF_NONE_MATCH'] + etag_matches?(none_match, headers) + elsif (modified_since = env['HTTP_IF_MODIFIED_SINCE']) && (modified_since = to_rfc2822(modified_since)) + modified_since?(modified_since, headers) + end end + # Whether the ETag response header matches the If-None-Match request header. + # If so, the request has not been modified. def etag_matches?(none_match, headers) - etag = headers['ETag'] and etag == none_match + headers['ETag'] == none_match end + # Whether the Last-Modified response header matches the If-Modified-Since + # request header. If so, the request has not been modified. def modified_since?(modified_since, headers) last_modified = to_rfc2822(headers['Last-Modified']) and - modified_since and modified_since >= last_modified end + # Return a Time object for the given string (which should be in RFC2822 + # format), or nil if the string cannot be parsed. def to_rfc2822(since) # shortest possible valid date is the obsolete: 1 Nov 97 09:55 A # anything shorter is invalid, this avoids exceptions for common cases @@ -73,8 +77,6 @@ module Rack # NOTE: there is no trivial way to write this in a non exception way # _rfc2822 returns a hash but is not that usable Time.rfc2822(since) rescue nil - else - nil end end end diff --git a/lib/rack/content_length.rb b/lib/rack/content_length.rb index e37fc3058fef23fa1bfe58a378b27f91017b9f87..9e2b5fc42a1c15c4bf36e6d741ff1a98ed45b88c 100644 --- a/lib/rack/content_length.rb +++ b/lib/rack/content_length.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true -require 'rack/utils' -require 'rack/body_proxy' - module Rack - # Sets the Content-Length header on responses with fixed-length bodies. + # Sets the Content-Length header on responses that do not specify + # a Content-Length or Transfer-Encoding header. Note that this + # does not fix responses that have an invalid Content-Length + # header specified. class ContentLength include Rack::Utils @@ -15,12 +15,11 @@ module Rack def call(env) status, headers, body = @app.call(env) - headers = HeaderHash.new(headers) + headers = HeaderHash[headers] if !STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) && !headers[CONTENT_LENGTH] && - !headers[TRANSFER_ENCODING] && - body.respond_to?(:to_ary) + !headers[TRANSFER_ENCODING] obody = body body, length = [], 0 diff --git a/lib/rack/content_type.rb b/lib/rack/content_type.rb index 010cc37b706c5bf4d2a8455be2c846f443317bd1..503f7070621e7e86ae9d31914f5931e364d0a3a3 100644 --- a/lib/rack/content_type.rb +++ b/lib/rack/content_type.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'rack/utils' - module Rack # Sets the Content-Type header on responses which don't have one. @@ -9,7 +7,8 @@ module Rack # Builder Usage: # use Rack::ContentType, "text/plain" # - # When no content type argument is provided, "text/html" is assumed. + # When no content type argument is provided, "text/html" is the + # default. class ContentType include Rack::Utils @@ -19,7 +18,7 @@ module Rack def call(env) status, headers, body = @app.call(env) - headers = Utils::HeaderHash.new(headers) + headers = Utils::HeaderHash[headers] unless STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) headers[CONTENT_TYPE] ||= @content_type diff --git a/lib/rack/deflater.rb b/lib/rack/deflater.rb index 9a30c017542f0442651f20eb2268d0d989ca72d6..e177fabb017c966f1a0fda7234db7956e56da39c 100644 --- a/lib/rack/deflater.rb +++ b/lib/rack/deflater.rb @@ -2,48 +2,47 @@ require "zlib" require "time" # for Time.httpdate -require 'rack/utils' - -require_relative 'core_ext/regexp' module Rack - # This middleware enables compression of http responses. + # This middleware enables content encoding of http responses, + # usually for purposes of compression. + # + # Currently supported encodings: # - # Currently supported compression algorithms: + # * gzip + # * identity (no transformation) # - # * gzip - # * identity (no transformation) + # This middleware automatically detects when encoding is supported + # and allowed. For example no encoding is made when a cache + # directive of 'no-transform' is present, when the response status + # code is one that doesn't allow an entity body, or when the body + # is empty. # - # The middleware automatically detects when compression is supported - # and allowed. For example no transformation is made when a cache - # directive of 'no-transform' is present, or when the response status - # code is one that doesn't allow an entity body. + # Note that despite the name, Deflater does not support the +deflate+ + # encoding. class Deflater - using ::Rack::RegexpExtensions + (require_relative 'core_ext/regexp'; using ::Rack::RegexpExtensions) if RUBY_VERSION < '2.4' - ## - # Creates Rack::Deflater middleware. + # Creates Rack::Deflater middleware. Options: # - # [app] rack app instance - # [options] hash of deflater options, i.e. - # 'if' - a lambda enabling / disabling deflation based on returned boolean value - # e.g use Rack::Deflater, :if => lambda { |*, body| sum=0; body.each { |i| sum += i.length }; sum > 512 } - # 'include' - a list of content types that should be compressed - # 'sync' - determines if the stream is going to be flushed after every chunk. - # Flushing after every chunk reduces latency for - # time-sensitive streaming applications, but hurts - # compression and throughput. Defaults to `true'. + # :if :: a lambda enabling / disabling deflation based on returned boolean value + # (e.g <tt>use Rack::Deflater, :if => lambda { |*, body| sum=0; body.each { |i| sum += i.length }; sum > 512 }</tt>). + # However, be aware that calling `body.each` inside the block will break cases where `body.each` is not idempotent, + # such as when it is an +IO+ instance. + # :include :: a list of content types that should be compressed. By default, all content types are compressed. + # :sync :: determines if the stream is going to be flushed after every chunk. Flushing after every chunk reduces + # latency for time-sensitive streaming applications, but hurts compression and throughput. + # Defaults to +true+. def initialize(app, options = {}) @app = app - @condition = options[:if] @compressible_types = options[:include] - @sync = options[:sync] == false ? false : true + @sync = options.fetch(:sync, true) end def call(env) status, headers, body = @app.call(env) - headers = Utils::HeaderHash.new(headers) + headers = Utils::HeaderHash[headers] unless should_deflate?(env, status, headers, body) return [status, headers, body] @@ -63,7 +62,7 @@ module Rack case encoding when "gzip" headers['Content-Encoding'] = "gzip" - headers.delete('Content-Length') + headers.delete(CONTENT_LENGTH) mtime = headers["Last-Modified"] mtime = Time.httpdate(mtime).to_i if mtime [status, headers, GzipStream.new(body, mtime, @sync)] @@ -72,49 +71,60 @@ module Rack when nil message = "An acceptable encoding for the requested resource #{request.fullpath} could not be found." bp = Rack::BodyProxy.new([message]) { body.close if body.respond_to?(:close) } - [406, { 'Content-Type' => "text/plain", 'Content-Length' => message.length.to_s }, bp] + [406, { CONTENT_TYPE => "text/plain", CONTENT_LENGTH => message.length.to_s }, bp] end end + # Body class used for gzip encoded responses. class GzipStream + # Initialize the gzip stream. Arguments: + # body :: Response body to compress with gzip + # mtime :: The modification time of the body, used to set the + # modification time in the gzip header. + # sync :: Whether to flush each gzip chunk as soon as it is ready. def initialize(body, mtime, sync) - @sync = sync @body = body @mtime = mtime + @sync = sync end + # Yield gzip compressed strings to the given block. def each(&block) @writer = block gzip = ::Zlib::GzipWriter.new(self) gzip.mtime = @mtime if @mtime @body.each { |part| - len = gzip.write(part) - # Flushing empty parts would raise Zlib::BufError. - gzip.flush if @sync && len > 0 + # Skip empty strings, as they would result in no output, + # and flushing empty parts would raise Zlib::BufError. + next if part.empty? + + gzip.write(part) + gzip.flush if @sync } ensure gzip.close - @writer = nil end + # Call the block passed to #each with the the gzipped data. def write(data) @writer.call(data) end + # Close the original body if possible. def close @body.close if @body.respond_to?(:close) - @body = nil end end private + # Whether the body should be compressed. def should_deflate?(env, status, headers, body) # Skip compressing empty entity body responses and responses with # no-transform set. if Utils::STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) || /\bno-transform\b/.match?(headers['Cache-Control'].to_s) || - (headers['Content-Encoding'] && headers['Content-Encoding'] !~ /\bidentity\b/) + headers['Content-Encoding']&.!~(/\bidentity\b/) return false end diff --git a/lib/rack/directory.rb b/lib/rack/directory.rb index 4c1f4dd61fa2903a53da305b76dc48938a179a82..be72be0144405996c18993a750f9de822a8a28ba 100644 --- a/lib/rack/directory.rb +++ b/lib/rack/directory.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true require 'time' -require 'rack/utils' -require 'rack/mime' -require 'rack/files' module Rack # Rack::Directory serves entries below the +root+ given, according to the @@ -14,8 +11,8 @@ module Rack # If +app+ is not specified, a Rack::Files of the same +root+ will be used. class Directory - DIR_FILE = "<tr><td class='name'><a href='%s'>%s</a></td><td class='size'>%s</td><td class='type'>%s</td><td class='mtime'>%s</td></tr>" - DIR_PAGE = <<-PAGE + DIR_FILE = "<tr><td class='name'><a href='%s'>%s</a></td><td class='size'>%s</td><td class='type'>%s</td><td class='mtime'>%s</td></tr>\n" + DIR_PAGE_HEADER = <<-PAGE <html><head> <title>%s</title> <meta http-equiv="content-type" content="text/html; charset=utf-8" /> @@ -36,33 +33,51 @@ table { width:100%%; } <th class='type'>Type</th> <th class='mtime'>Last Modified</th> </tr> -%s + PAGE + DIR_PAGE_FOOTER = <<-PAGE </table> <hr /> </body></html> PAGE + # Body class for directory entries, showing an index page with links + # to each file. class DirectoryBody < Struct.new(:root, :path, :files) + # Yield strings for each part of the directory entry def each - show_path = Rack::Utils.escape_html(path.sub(/^#{root}/, '')) - listings = files.map{|f| DIR_FILE % DIR_FILE_escape(*f) } * "\n" - page = DIR_PAGE % [ show_path, show_path, listings ] - page.each_line{|l| yield l } + show_path = Utils.escape_html(path.sub(/^#{root}/, '')) + yield(DIR_PAGE_HEADER % [ show_path, show_path ]) + + unless path.chomp('/') == root + yield(DIR_FILE % DIR_FILE_escape(files.call('..'))) + end + + Dir.foreach(path) do |basename| + next if basename.start_with?('.') + next unless f = files.call(basename) + yield(DIR_FILE % DIR_FILE_escape(f)) + end + + yield(DIR_PAGE_FOOTER) end private - # Assumes url is already escaped. - def DIR_FILE_escape url, *html - [url, *html.map { |e| Utils.escape_html(e) }] + + # Escape each element in the array of html strings. + def DIR_FILE_escape(htmls) + htmls.map { |e| Utils.escape_html(e) } end end - attr_reader :root, :path + # The root of the directory hierarchy. Only requests for files and + # directories inside of the root directory are supported. + attr_reader :root + # Set the root directory and application for serving files. def initialize(root, app = nil) @root = ::File.expand_path(root) - @app = app || Rack::Files.new(@root) - @head = Rack::Head.new(lambda { |env| get env }) + @app = app || Files.new(@root) + @head = Head.new(method(:get)) end def call(env) @@ -70,100 +85,101 @@ table { width:100%%; } @head.call env end + # Internals of request handling. Similar to call but does + # not remove body for HEAD requests. def get(env) script_name = env[SCRIPT_NAME] path_info = Utils.unescape_path(env[PATH_INFO]) - if bad_request = check_bad_request(path_info) - bad_request - elsif forbidden = check_forbidden(path_info) - forbidden + if client_error_response = check_bad_request(path_info) || check_forbidden(path_info) + client_error_response else path = ::File.join(@root, path_info) list_path(env, path, path_info, script_name) end end + # Rack response to use for requests with invalid paths, or nil if path is valid. def check_bad_request(path_info) return if Utils.valid_path?(path_info) body = "Bad Request\n" - size = body.bytesize - return [400, { CONTENT_TYPE => "text/plain", - CONTENT_LENGTH => size.to_s, + [400, { CONTENT_TYPE => "text/plain", + CONTENT_LENGTH => body.bytesize.to_s, "X-Cascade" => "pass" }, [body]] end + # Rack response to use for requests with paths outside the root, or nil if path is inside the root. def check_forbidden(path_info) return unless path_info.include? ".." + return if ::File.expand_path(::File.join(@root, path_info)).start_with?(@root) body = "Forbidden\n" - size = body.bytesize - return [403, { CONTENT_TYPE => "text/plain", - CONTENT_LENGTH => size.to_s, + [403, { CONTENT_TYPE => "text/plain", + CONTENT_LENGTH => body.bytesize.to_s, "X-Cascade" => "pass" }, [body]] end + # Rack response to use for directories under the root. def list_directory(path_info, path, script_name) - files = [['../', 'Parent Directory', '', '', '']] - url_head = (script_name.split('/') + path_info.split('/')).map do |part| - Rack::Utils.escape_path part + Utils.escape_path part end - Dir.entries(path).reject { |e| e.start_with?('.') }.sort.each do |node| - node = ::File.join path, node - stat = stat(node) + # Globbing not safe as path could contain glob metacharacters + body = DirectoryBody.new(@root, path, ->(basename) do + stat = stat(::File.join(path, basename)) next unless stat - basename = ::File.basename(node) - ext = ::File.extname(node) - url = ::File.join(*url_head + [Rack::Utils.escape_path(basename)]) - size = stat.size - type = stat.directory? ? 'directory' : Mime.mime_type(ext) - size = stat.directory? ? '-' : filesize_format(size) + url = ::File.join(*url_head + [Utils.escape_path(basename)]) mtime = stat.mtime.httpdate - url << '/' if stat.directory? - basename << '/' if stat.directory? - - files << [ url, basename, size, type, mtime ] - end - - return [ 200, { CONTENT_TYPE => 'text/html; charset=utf-8' }, DirectoryBody.new(@root, path, files) ] + if stat.directory? + type = 'directory' + size = '-' + url << '/' + if basename == '..' + basename = 'Parent Directory' + else + basename << '/' + end + else + type = Mime.mime_type(::File.extname(basename)) + size = filesize_format(stat.size) + end + + [ url, basename, size, type, mtime ] + end) + + [ 200, { CONTENT_TYPE => 'text/html; charset=utf-8' }, body ] end - def stat(node) - ::File.stat(node) + # File::Stat for the given path, but return nil for missing/bad entries. + def stat(path) + ::File.stat(path) rescue Errno::ENOENT, Errno::ELOOP return nil end - # TODO: add correct response if not readable, not sure if 404 is the best - # option + # Rack response to use for files and directories under the root. + # Unreadable and non-file, non-directory entries will get a 404 response. def list_path(env, path, path_info, script_name) - stat = ::File.stat(path) - - if stat.readable? + if (stat = stat(path)) && stat.readable? return @app.call(env) if stat.file? return list_directory(path_info, path, script_name) if stat.directory? - else - raise Errno::ENOENT, 'No such file or directory' end - rescue Errno::ENOENT, Errno::ELOOP - return entity_not_found(path_info) + entity_not_found(path_info) end + # Rack response to use for unreadable and non-file, non-directory entries. def entity_not_found(path_info) body = "Entity not found: #{path_info}\n" - size = body.bytesize - return [404, { CONTENT_TYPE => "text/plain", - CONTENT_LENGTH => size.to_s, + [404, { CONTENT_TYPE => "text/plain", + CONTENT_LENGTH => body.bytesize.to_s, "X-Cascade" => "pass" }, [body]] end # Stolen from Ramaze - FILESIZE_FORMAT = [ ['%.1fT', 1 << 40], ['%.1fG', 1 << 30], @@ -171,6 +187,7 @@ table { width:100%%; } ['%.1fK', 1 << 10], ] + # Provide human readable file sizes def filesize_format(int) FILESIZE_FORMAT.each do |format, size| return format % (int.to_f / size) if int >= size diff --git a/lib/rack/etag.rb b/lib/rack/etag.rb index fd3de554a3ad35e7d865a7d8f2fdcaeb7f4922ec..5039437e1c13e6ad458d071fb4d3c438247589ad 100644 --- a/lib/rack/etag.rb +++ b/lib/rack/etag.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'rack' +require_relative '../rack' require 'digest/sha2' module Rack @@ -26,6 +26,8 @@ module Rack def call(env) status, headers, body = @app.call(env) + headers = Utils::HeaderHash[headers] + if etag_status?(status) && etag_body?(body) && !skip_caching?(headers) original_body = body digest, new_body = digest_body(body) @@ -57,8 +59,7 @@ module Rack end def skip_caching?(headers) - (headers[CACHE_CONTROL] && headers[CACHE_CONTROL].include?('no-cache')) || - headers.key?(ETAG_STRING) || headers.key?('Last-Modified') + headers.key?(ETAG_STRING) || headers.key?('Last-Modified') end def digest_body(body) diff --git a/lib/rack/events.rb b/lib/rack/events.rb index 77b716754f227c7676271ff3a003235c64cdbe8a..65055fdc51e6d8e8a77d66d013cceb414f06a334 100644 --- a/lib/rack/events.rb +++ b/lib/rack/events.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require 'rack/response' -require 'rack/body_proxy' - module Rack ### This middleware provides hooks to certain places in the request / # response lifecycle. This is so that middleware that don't need to filter @@ -59,26 +56,26 @@ module Rack class Events module Abstract - def on_start req, res + def on_start(req, res) end - def on_commit req, res + def on_commit(req, res) end - def on_send req, res + def on_send(req, res) end - def on_finish req, res + def on_finish(req, res) end - def on_error req, res, e + def on_error(req, res, e) end end class EventedBodyProxy < Rack::BodyProxy # :nodoc: attr_reader :request, :response - def initialize body, request, response, handlers, &block + def initialize(body, request, response, handlers, &block) super(body, &block) @request = request @response = response @@ -94,7 +91,7 @@ module Rack class BufferedResponse < Rack::Response::Raw # :nodoc: attr_reader :body - def initialize status, headers, body + def initialize(status, headers, body) super(status, headers) @body = body end @@ -102,12 +99,12 @@ module Rack def to_a; [status, headers, body]; end end - def initialize app, handlers + def initialize(app, handlers) @app = app @handlers = handlers end - def call env + def call(env) request = make_request env on_start request, nil @@ -129,27 +126,27 @@ module Rack private - def on_error request, response, e + def on_error(request, response, e) @handlers.reverse_each { |handler| handler.on_error request, response, e } end - def on_commit request, response + def on_commit(request, response) @handlers.reverse_each { |handler| handler.on_commit request, response } end - def on_start request, response + def on_start(request, response) @handlers.each { |handler| handler.on_start request, nil } end - def on_finish request, response + def on_finish(request, response) @handlers.reverse_each { |handler| handler.on_finish request, response } end - def make_request env + def make_request(env) Rack::Request.new env end - def make_response status, headers, body + def make_response(status, headers, body) BufferedResponse.new status, headers, body end end diff --git a/lib/rack/file.rb b/lib/rack/file.rb index 52b48e8bac52c8fe786029a5a45c397d1534ad84..fdcf9b3ec064550319844363e57d0031061ed112 100644 --- a/lib/rack/file.rb +++ b/lib/rack/file.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'rack/files' +require_relative 'files' module Rack File = Files diff --git a/lib/rack/files.rb b/lib/rack/files.rb index f1a91c8bcef23a1538b3d030cb38291a5d55206f..e745eb3984373dde5dded309376f722b5f697cdf 100644 --- a/lib/rack/files.rb +++ b/lib/rack/files.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true require 'time' -require 'rack/utils' -require 'rack/mime' -require 'rack/request' -require 'rack/head' module Rack # Rack::Files serves files below the +root+ directory given, according to the @@ -18,6 +14,15 @@ module Rack class Files ALLOWED_VERBS = %w[GET HEAD OPTIONS] ALLOW_HEADER = ALLOWED_VERBS.join(', ') + MULTIPART_BOUNDARY = 'AaB03x' + + # @todo remove in 3.0 + def self.method_added(name) + if name == :response_body + raise "#{self.class}\#response_body is no longer supported." + end + super + end attr_reader :root @@ -48,7 +53,11 @@ module Rack available = begin ::File.file?(path) && ::File.readable?(path) rescue SystemCallError + # Not sure in what conditions this exception can occur, but this + # is a safe way to handle such an error. + # :nocov: false + # :nocov: end if available @@ -70,75 +79,116 @@ module Rack headers[CONTENT_TYPE] = mime_type if mime_type # Set custom headers - @headers.each { |field, content| headers[field] = content } if @headers - - response = [ 200, headers ] + headers.merge!(@headers) if @headers + status = 200 size = filesize path - range = nil ranges = Rack::Utils.get_byte_ranges(request.get_header('HTTP_RANGE'), size) - if ranges.nil? || ranges.length > 1 - # No ranges, or multiple ranges (which we don't support): - # TODO: Support multiple byte-ranges - response[0] = 200 - range = 0..size - 1 + if ranges.nil? + # No ranges: + ranges = [0..size - 1] elsif ranges.empty? # Unsatisfiable. Return error, and file size: response = fail(416, "Byte range unsatisfiable") response[1]["Content-Range"] = "bytes */#{size}" return response - else - # Partial content: - range = ranges[0] - response[0] = 206 - response[1]["Content-Range"] = "bytes #{range.begin}-#{range.end}/#{size}" - size = range.end - range.begin + 1 + elsif ranges.size >= 1 + # Partial content + partial_content = true + + if ranges.size == 1 + range = ranges[0] + headers["Content-Range"] = "bytes #{range.begin}-#{range.end}/#{size}" + else + headers[CONTENT_TYPE] = "multipart/byteranges; boundary=#{MULTIPART_BOUNDARY}" + end + + status = 206 + body = BaseIterator.new(path, ranges, mime_type: mime_type, size: size) + size = body.bytesize end - response[2] = [response_body] unless response_body.nil? + headers[CONTENT_LENGTH] = size.to_s - response[1][CONTENT_LENGTH] = size.to_s - response[2] = make_body request, path, range - response + if request.head? + body = [] + elsif !partial_content + body = Iterator.new(path, ranges, mime_type: mime_type, size: size) + end + + [status, headers, body] end - class Iterator - attr_reader :path, :range - alias :to_path :path + class BaseIterator + attr_reader :path, :ranges, :options - def initialize path, range - @path = path - @range = range + def initialize(path, ranges, options) + @path = path + @ranges = ranges + @options = options end def each ::File.open(path, "rb") do |file| - file.seek(range.begin) - remaining_len = range.end - range.begin + 1 - while remaining_len > 0 - part = file.read([8192, remaining_len].min) - break unless part - remaining_len -= part.length - - yield part + ranges.each do |range| + yield multipart_heading(range) if multipart? + + each_range_part(file, range) do |part| + yield part + end end + + yield "\r\n--#{MULTIPART_BOUNDARY}--\r\n" if multipart? end end + def bytesize + size = ranges.inject(0) do |sum, range| + sum += multipart_heading(range).bytesize if multipart? + sum += range.size + end + size += "\r\n--#{MULTIPART_BOUNDARY}--\r\n".bytesize if multipart? + size + end + def close; end - end - private + private - def make_body request, path, range - if request.head? - [] - else - Iterator.new path, range + def multipart? + ranges.size > 1 + end + + def multipart_heading(range) +<<-EOF +\r +--#{MULTIPART_BOUNDARY}\r +Content-Type: #{options[:mime_type]}\r +Content-Range: bytes #{range.begin}-#{range.end}/#{options[:size]}\r +\r +EOF + end + + def each_range_part(file, range) + file.seek(range.begin) + remaining_len = range.end - range.begin + 1 + while remaining_len > 0 + part = file.read([8192, remaining_len].min) + break unless part + remaining_len -= part.length + + yield part + end end end + class Iterator < BaseIterator + alias :to_path :path + end + + private + def fail(status, body, headers = {}) body += "\n" @@ -154,25 +204,15 @@ module Rack end # The MIME type for the contents of the file located at @path - def mime_type path, default_mime + def mime_type(path, default_mime) Mime.mime_type(::File.extname(path), default_mime) end - def filesize path - # If response_body is present, use its size. - return response_body.bytesize if response_body - + def filesize(path) # We check via File::size? whether this file provides size info # via stat (e.g. /proc files often don't), otherwise we have to # figure it out by reading the whole file into memory. ::File.size?(path) || ::File.read(path).bytesize end - - # By default, the response body for file requests is nil. - # In this case, the response body will be generated later - # from the file at @path - def response_body - nil - end end end diff --git a/lib/rack/handler/cgi.rb b/lib/rack/handler/cgi.rb index a223c5453e98138d0e9d5084cc647d49bca14889..1c11ab360622c22068bf353d2b80d7a288344833 100644 --- a/lib/rack/handler/cgi.rb +++ b/lib/rack/handler/cgi.rb @@ -1,12 +1,9 @@ # frozen_string_literal: true -require 'rack/content_length' -require 'rack/rewindable_input' - module Rack module Handler class CGI - def self.run(app, options = nil) + def self.run(app, **options) $stdin.binmode serve app end diff --git a/lib/rack/handler/fastcgi.rb b/lib/rack/handler/fastcgi.rb index b3f825dac990b1e2a9dbf0a14547cfc0cb19514b..1df123e02a65a73c2a365215082ffe1787562122 100644 --- a/lib/rack/handler/fastcgi.rb +++ b/lib/rack/handler/fastcgi.rb @@ -2,8 +2,6 @@ require 'fcgi' require 'socket' -require 'rack/content_length' -require 'rack/rewindable_input' if defined? FCGI::Stream class FCGI::Stream @@ -20,7 +18,7 @@ end module Rack module Handler class FastCGI - def self.run(app, options = {}) + def self.run(app, **options) if options[:File] STDIN.reopen(UNIXServer.new(options[:File])) elsif options[:Port] diff --git a/lib/rack/handler/lsws.rb b/lib/rack/handler/lsws.rb index 803182a2dd310359bdce654e997ce79435234a78..f12090bd62df9968784d1f769f6aaf8ae50eef0b 100644 --- a/lib/rack/handler/lsws.rb +++ b/lib/rack/handler/lsws.rb @@ -1,13 +1,11 @@ # frozen_string_literal: true require 'lsapi' -require 'rack/content_length' -require 'rack/rewindable_input' module Rack module Handler class LSWS - def self.run(app, options = nil) + def self.run(app, **options) while LSAPI.accept != nil serve app end diff --git a/lib/rack/handler/scgi.rb b/lib/rack/handler/scgi.rb index c8e916061bbdac7a3a5b082757470b5e2306baf6..e3b8d3c6f240a9c6b426b28b84eabefc8a36c9e3 100644 --- a/lib/rack/handler/scgi.rb +++ b/lib/rack/handler/scgi.rb @@ -2,15 +2,13 @@ require 'scgi' require 'stringio' -require 'rack/content_length' -require 'rack/chunked' module Rack module Handler class SCGI < ::SCGI::Processor attr_accessor :app - def self.run(app, options = nil) + def self.run(app, **options) options[:Socket] = UNIXServer.new(options[:File]) if options[:File] new(options.merge(app: app, host: options[:Host], diff --git a/lib/rack/handler/thin.rb b/lib/rack/handler/thin.rb index 100dfd11947bb326035ff6073dedb8c38f06df4b..393a6e98699dca62de2b928e8183f9f56eeafbf9 100644 --- a/lib/rack/handler/thin.rb +++ b/lib/rack/handler/thin.rb @@ -4,13 +4,11 @@ require "thin" require "thin/server" require "thin/logging" require "thin/backends/tcp_server" -require "rack/content_length" -require "rack/chunked" module Rack module Handler class Thin - def self.run(app, options = {}) + def self.run(app, **options) environment = ENV['RACK_ENV'] || 'development' default_host = environment == 'development' ? 'localhost' : '0.0.0.0' diff --git a/lib/rack/handler/webrick.rb b/lib/rack/handler/webrick.rb index 4affdbde66c6021a5b9acfc13d4990bfeea5a86e..d2f389758a33c48e125a4dc4af76dc041b9830de 100644 --- a/lib/rack/handler/webrick.rb +++ b/lib/rack/handler/webrick.rb @@ -2,7 +2,6 @@ require 'webrick' require 'stringio' -require 'rack/content_length' # This monkey patch allows for applications to perform their own chunking # through WEBrick::HTTPResponse if rack is set to true. @@ -24,12 +23,18 @@ end module Rack module Handler class WEBrick < ::WEBrick::HTTPServlet::AbstractServlet - def self.run(app, options = {}) + def self.run(app, **options) environment = ENV['RACK_ENV'] || 'development' default_host = environment == 'development' ? 'localhost' : nil - options[:BindAddress] = options.delete(:Host) || default_host + if !options[:BindAddress] || options[:Host] + options[:BindAddress] = options.delete(:Host) || default_host + end options[:Port] ||= 8080 + if options[:SSLEnable] + require 'webrick/https' + end + @server = ::WEBrick::HTTPServer.new(options) @server.mount "/", Rack::Handler::WEBrick, app yield @server if block_given? @@ -47,8 +52,10 @@ module Rack end def self.shutdown - @server.shutdown - @server = nil + if @server + @server.shutdown + @server = nil + end end def initialize(server, app) diff --git a/lib/rack/head.rb b/lib/rack/head.rb index c257ae4d5d72120f088295275c0df37aee93dab4..8025a27d514c2fe57ad8436718639da532f2a996 100644 --- a/lib/rack/head.rb +++ b/lib/rack/head.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'rack/body_proxy' - module Rack # Rack::Head returns an empty body for all HEAD requests. It leaves # all other requests unchanged. diff --git a/lib/rack/lint.rb b/lib/rack/lint.rb index 98ba9b44111939d1160bc40bd36c4e72c30510b0..67d2eb1294c27005e097d9d82aa50094b2d68fbc 100644 --- a/lib/rack/lint.rb +++ b/lib/rack/lint.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require 'rack/utils' require 'forwardable' module Rack @@ -48,13 +47,24 @@ module Rack env[RACK_ERRORS] = ErrorWrapper.new(env[RACK_ERRORS]) ## and returns an Array of exactly three values: - status, headers, @body = @app.call(env) + ary = @app.call(env) + assert("response is not an Array, but #{ary.class}") { + ary.kind_of? Array + } + assert("response array has #{ary.size} elements instead of 3") { + ary.size == 3 + } + + status, headers, @body = ary ## The *status*, check_status status ## the *headers*, check_headers headers - check_hijack_response headers, env + hijack_proc = check_hijack_response headers, env + if hijack_proc && headers.is_a?(Hash) + headers[RACK_HIJACK] = hijack_proc + end ## and the *body*. check_content_type status, headers @@ -65,12 +75,15 @@ module Rack ## == The Environment def check_env(env) - ## The environment must be an instance of Hash that includes + ## The environment must be an unfrozen instance of Hash that includes ## CGI-like headers. The application is free to modify the ## environment. assert("env #{env.inspect} is not a Hash, but #{env.class}") { env.kind_of? Hash } + assert("env should not be frozen, but is") { + !env.frozen? + } ## ## The environment is required to include these variables @@ -104,17 +117,19 @@ 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>SERVER_NAME</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> can never be an empty + ## string, and so is always required. + + ## <tt>SERVER_PORT</tt>:: An optional +Integer+ which is the port the + ## server is running on. Should be specified if + ## the server is running on a non-standard port. ## <tt>HTTP_</tt> Variables:: Variables corresponding to the ## client-supplied HTTP request @@ -198,6 +213,11 @@ module Rack assert("session #{session.inspect} must respond to clear") { session.respond_to?(:clear) } + + ## to_hash (returning unfrozen Hash instance); + assert("session #{session.inspect} must respond to to_hash and return unfrozen Hash instance") { + session.respond_to?(:to_hash) && session.to_hash.kind_of?(Hash) && !session.to_hash.frozen? + } end ## <tt>rack.logger</tt>:: A common object interface for logging messages. @@ -253,13 +273,28 @@ module Rack ## accepted specifications and must not be used otherwise. ## - %w[REQUEST_METHOD SERVER_NAME SERVER_PORT - QUERY_STRING + %w[REQUEST_METHOD SERVER_NAME QUERY_STRING rack.version rack.input rack.errors rack.multithread rack.multiprocess rack.run_once].each { |header| assert("env missing required key #{header}") { env.include? header } } + ## The <tt>SERVER_PORT</tt> must be an Integer if set. + assert("env[SERVER_PORT] is not an Integer") do + server_port = env["SERVER_PORT"] + server_port.nil? || (Integer(server_port) rescue false) + end + + ## The <tt>SERVER_NAME</tt> must be a valid authority as defined by RFC7540. + assert("#{env[SERVER_NAME]} must be a valid authority") do + URI.parse("http://#{env[SERVER_NAME]}/") rescue false + end + + ## The <tt>HTTP_HOST</tt> must be a valid authority as defined by RFC7540. + assert("#{env[HTTP_HOST]} must be a valid authority") do + URI.parse("http://#{env[HTTP_HOST]}/") rescue false + 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>). @@ -270,11 +305,17 @@ module Rack } ## The CGI keys (named without a period) must have String values. + ## If the string values for CGI keys contain non-ASCII characters, + ## they should use ASCII-8BIT encoding. env.each { |key, value| next if key.include? "." # Skip extensions assert("env variable #{key} has non-string value #{value.inspect}") { value.kind_of? String } + next if value.encoding == Encoding::ASCII_8BIT + assert("env variable #{key} has value containing non-ASCII characters and has non-ASCII-8BIT encoding #{value.inspect} encoding: #{value.encoding}") { + value.b !~ /[\x80-\xff]/n + } } ## There are the following restrictions: @@ -296,7 +337,7 @@ module Rack check_hijack env ## * The <tt>REQUEST_METHOD</tt> must be a valid token. - assert("REQUEST_METHOD unknown: #{env[REQUEST_METHOD]}") { + assert("REQUEST_METHOD unknown: #{env[REQUEST_METHOD].dump}") { env[REQUEST_METHOD] =~ /\A[0-9A-Za-z!\#$%&'*+.^_`|~-]+\z/ } @@ -337,7 +378,7 @@ module Rack ## When applicable, its external encoding must be "ASCII-8BIT" and it ## must be opened in binary mode, for Ruby 1.9 compatibility. assert("rack.input #{input} does not have ASCII-8BIT as its external encoding") { - input.external_encoding.name == "ASCII-8BIT" + input.external_encoding == Encoding::ASCII_8BIT } if input.respond_to?(:external_encoding) assert("rack.input #{input} is not opened in binary mode") { input.binmode? @@ -569,7 +610,7 @@ module Rack # this check uses headers like a hash, but the spec only requires # headers respond to #each - headers = Rack::Utils::HeaderHash.new(headers) + headers = Rack::Utils::HeaderHash[headers] ## In order to do this, an application may set the special header ## <tt>rack.hijack</tt> to an object that responds to <tt>call</tt> @@ -593,7 +634,7 @@ module Rack headers[RACK_HIJACK].respond_to? :call } original_hijack = headers[RACK_HIJACK] - headers[RACK_HIJACK] = proc do |io| + proc do |io| original_hijack.call HijackWrapper.new(io) end else @@ -603,6 +644,8 @@ module Rack assert('rack.hijack header must not be present if server does not support hijacking') { headers[RACK_HIJACK].nil? } + + nil end end ## ==== Conventions diff --git a/lib/rack/lobster.rb b/lib/rack/lobster.rb index 77b607c3180d3cbde0c007e1d08fdf2198b10971..b86a625de0c2fcc241b00badd9840344454af499 100644 --- a/lib/rack/lobster.rb +++ b/lib/rack/lobster.rb @@ -2,9 +2,6 @@ require 'zlib' -require 'rack/request' -require 'rack/response' - module Rack # Paste has a Pony, Rack has a Lobster! class Lobster @@ -64,9 +61,10 @@ module Rack end if $0 == __FILE__ - require 'rack' - require 'rack/show_exceptions' + # :nocov: + require_relative '../rack' Rack::Server.start( app: Rack::ShowExceptions.new(Rack::Lint.new(Rack::Lobster.new)), Port: 9292 ) + # :nocov: end diff --git a/lib/rack/lock.rb b/lib/rack/lock.rb index 96366cd306f7857cd8b1df0f81879638353ad763..4bae3a9034e83b50d3dfbf2fad13722df25fd0a6 100644 --- a/lib/rack/lock.rb +++ b/lib/rack/lock.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'thread' -require 'rack/body_proxy' module Rack # Rack::Lock locks every request inside a mutex, so that every request diff --git a/lib/rack/method_override.rb b/lib/rack/method_override.rb index 453901fc60692f43ac042e1f50e411d77b9df60d..b586f5339b6fb13313979fc530d5a6b0cd224fec 100644 --- a/lib/rack/method_override.rb +++ b/lib/rack/method_override.rb @@ -43,7 +43,7 @@ module Rack def method_override_param(req) req.POST[METHOD_OVERRIDE_PARAM_KEY] - rescue Utils::InvalidParameterError, Utils::ParameterTypeError + rescue Utils::InvalidParameterError, Utils::ParameterTypeError, QueryParser::ParamsTooDeepError req.get_header(RACK_ERRORS).puts "Invalid or incomplete POST params" rescue EOFError req.get_header(RACK_ERRORS).puts "Bad request content body" diff --git a/lib/rack/mock.rb b/lib/rack/mock.rb index 3feaedd91882e8828d41061914f59b7a453519c7..5b2512ca091dec59ee53a65a17108a4662f3d569 100644 --- a/lib/rack/mock.rb +++ b/lib/rack/mock.rb @@ -2,10 +2,7 @@ require 'uri' require 'stringio' -require 'rack' -require 'rack/lint' -require 'rack/utils' -require 'rack/response' +require_relative '../rack' require 'cgi/cookie' module Rack @@ -56,14 +53,24 @@ module Rack @app = app end + # Make a GET request and return a MockResponse. See #request. def get(uri, opts = {}) request(GET, uri, opts) end + # Make a POST request and return a MockResponse. See #request. def post(uri, opts = {}) request(POST, uri, opts) end + # Make a PUT request and return a MockResponse. See #request. def put(uri, opts = {}) request(PUT, uri, opts) end + # Make a PATCH request and return a MockResponse. See #request. def patch(uri, opts = {}) request(PATCH, uri, opts) end + # Make a DELETE request and return a MockResponse. See #request. def delete(uri, opts = {}) request(DELETE, uri, opts) end + # Make a HEAD request and return a MockResponse. See #request. def head(uri, opts = {}) request(HEAD, uri, opts) end + # Make an OPTIONS request and return a MockResponse. See #request. def options(uri, opts = {}) request(OPTIONS, uri, opts) end + # Make a request using the given request method for the given + # uri to the rack application and return a MockResponse. + # Options given are passed to MockRequest.env_for. def request(method = GET, uri = "", opts = {}) env = self.class.env_for(uri, opts.merge(method: method)) @@ -88,6 +95,13 @@ module Rack end # Return the Rack environment used for a request to +uri+. + # All options that are strings are added to the returned environment. + # Options: + # :fatal :: Whether to raise an exception if request outputs to rack.errors + # :input :: The rack.input to set + # :method :: The HTTP request method to use + # :params :: The params to use + # :script_name :: The SCRIPT_NAME to set def self.env_for(uri = "", opts = {}) uri = parse_uri_rfc2396(uri) uri.path = "/#{uri.path}" unless uri.path[0] == ?/ @@ -157,6 +171,10 @@ module Rack # MockRequest. class MockResponse < Rack::Response + class << self + alias [] new + end + # Headers attr_reader :original_headers, :cookies diff --git a/lib/rack/multipart.rb b/lib/rack/multipart.rb index bd91f43f4fcaeacf8c6383c376f4421e278dbdfb..fdae808a83fdc329e086e1b83194916eddf1ef8d 100644 --- a/lib/rack/multipart.rb +++ b/lib/rack/multipart.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'rack/multipart/parser' +require_relative 'multipart/parser' module Rack # A multipart form data parser, adapted from IOWA. @@ -16,13 +16,12 @@ module Rack TOKEN = /[^\s()<>,;:\\"\/\[\]?=]+/ CONDISP = /Content-Disposition:\s*#{TOKEN}\s*/i VALUE = /"(?:\\"|[^"])*"|#{TOKEN}/ - BROKEN_QUOTED = /^#{CONDISP}.*;\s*filename="(.*?)"(?:\s*$|\s*;\s*#{TOKEN}=)/i - BROKEN_UNQUOTED = /^#{CONDISP}.*;\s*filename=(#{TOKEN})/i + BROKEN = /^#{CONDISP}.*;\s*filename=(#{VALUE})/i MULTIPART_CONTENT_TYPE = /Content-Type: (.*)#{EOL}/ni - MULTIPART_CONTENT_DISPOSITION = /Content-Disposition:.*;\s*name=(#{VALUE})/ni + MULTIPART_CONTENT_DISPOSITION = /Content-Disposition:[^:]*;\s*name=(#{VALUE})/ni MULTIPART_CONTENT_ID = /Content-ID:\s*([^#{EOL}]*)/ni # Updated definitions from RFC 2231 - ATTRIBUTE_CHAR = %r{[^ \t\v\n\r)(><@,;:\\"/\[\]?='*%]} + ATTRIBUTE_CHAR = %r{[^ \x00-\x1f\x7f)(><@,;:\\"/\[\]?='*%]} ATTRIBUTE = /#{ATTRIBUTE_CHAR}+/ SECTION = /\*[0-9]+/ REGULAR_PARAMETER_NAME = /#{ATTRIBUTE}#{SECTION}?/ diff --git a/lib/rack/multipart/generator.rb b/lib/rack/multipart/generator.rb index 9ed2bb07cd4f35946d31937ada9ec67f93bf434c..f798a98c5101253082d62041d8ab10a4b32d3b0a 100644 --- a/lib/rack/multipart/generator.rb +++ b/lib/rack/multipart/generator.rb @@ -17,9 +17,13 @@ module Rack flattened_params.map do |name, file| if file.respond_to?(:original_filename) - ::File.open(file.path, 'rb') do |f| - f.set_encoding(Encoding::BINARY) - content_for_tempfile(f, file, name) + if file.path + ::File.open(file.path, 'rb') do |f| + f.set_encoding(Encoding::BINARY) + content_for_tempfile(f, file, name) + end + else + content_for_tempfile(file, file, name) end else content_for_other(file, name) @@ -69,12 +73,13 @@ module Rack end def content_for_tempfile(io, file, name) + length = ::File.stat(file.path).size if file.path + filename = "; filename=\"#{Utils.escape(file.original_filename)}\"" if file.original_filename <<-EOF --#{MULTIPART_BOUNDARY}\r -Content-Disposition: form-data; name="#{name}"; filename="#{Utils.escape(file.original_filename)}"\r +Content-Disposition: form-data; name="#{name}"#{filename}\r Content-Type: #{file.content_type}\r -Content-Length: #{::File.stat(file.path).size}\r -\r +#{"Content-Length: #{length}\r\n" if length}\r #{io.read}\r EOF end diff --git a/lib/rack/multipart/parser.rb b/lib/rack/multipart/parser.rb index 3e30a6b4ac250ab59d13c73c4584720d2096911b..0fc185603101c477352e9d077148478a82deac29 100644 --- a/lib/rack/multipart/parser.rb +++ b/lib/rack/multipart/parser.rb @@ -1,15 +1,14 @@ # frozen_string_literal: true -require 'rack/utils' require 'strscan' -require 'rack/core_ext/regexp' module Rack module Multipart class MultipartPartLimitError < Errno::EMFILE; end + class MultipartTotalPartLimitError < StandardError; end class Parser - using ::Rack::RegexpExtensions + (require_relative '../core_ext/regexp'; using ::Rack::RegexpExtensions) if RUBY_VERSION < '2.4' BUFSIZE = 1_048_576 TEXT_PLAIN = "text/plain" @@ -101,12 +100,6 @@ module Rack data = { filename: fn, type: content_type, name: name, tempfile: body, head: head } - elsif !filename && content_type && body.is_a?(IO) - body.rewind - - # Generic multipart cases, not coming from a form - data = { type: content_type, - name: name, tempfile: body, head: head } end yield data @@ -125,7 +118,7 @@ module Rack include Enumerable - def initialize tempfile + def initialize(tempfile) @tempfile = tempfile @mime_parts = [] @open_files = 0 @@ -135,7 +128,7 @@ module Rack @mime_parts.each { |part| yield part } end - def on_mime_head mime_index, head, filename, content_type, name + def on_mime_head(mime_index, head, filename, content_type, name) if filename body = @tempfile.call(filename, content_type) body.binmode if body.respond_to?(:binmode) @@ -148,25 +141,35 @@ module Rack @mime_parts[mime_index] = klass.new(body, head, filename, content_type, name) - check_open_files + check_part_limits end - def on_mime_body mime_index, content + def on_mime_body(mime_index, content) @mime_parts[mime_index].body << content end - def on_mime_finish mime_index + def on_mime_finish(mime_index) end private - def check_open_files - if Utils.multipart_part_limit > 0 - if @open_files >= Utils.multipart_part_limit + def check_part_limits + file_limit = Utils.multipart_file_limit + part_limit = Utils.multipart_total_part_limit + + if file_limit && file_limit > 0 + if @open_files >= file_limit @mime_parts.each(&:close) raise MultipartPartLimitError, 'Maximum file multiparts in content reached' end end + + if part_limit && part_limit > 0 + if @mime_parts.size >= part_limit + @mime_parts.each(&:close) + raise MultipartTotalPartLimitError, 'Maximum total multiparts in content reached' + end + end end end @@ -190,7 +193,7 @@ module Rack @head_regex = /(.*?#{EOL})#{EOL}/m end - def on_read content + def on_read(content) handle_empty_content!(content) @sbuf.concat content run_parser @@ -309,8 +312,9 @@ module Rack elsif filename = params['filename*'] encoding, _, filename = filename.split("'", 3) end - when BROKEN_QUOTED, BROKEN_UNQUOTED + when BROKEN filename = $1 + filename = $1 if filename =~ /^"(.*)"$/ end return unless filename @@ -347,7 +351,7 @@ module Rack type_subtype = list.first type_subtype.strip! if TEXT_PLAIN == type_subtype - rest = list.drop 1 + rest = list.drop 1 rest.each do |param| k, v = param.split('=', 2) k.strip! diff --git a/lib/rack/multipart/uploaded_file.rb b/lib/rack/multipart/uploaded_file.rb index d01f2d6f32fc414ac6af533f30723d8a0634bb3d..9eaf691277b1e98f0c2439b5ebe52cfa7bc94e21 100644 --- a/lib/rack/multipart/uploaded_file.rb +++ b/lib/rack/multipart/uploaded_file.rb @@ -9,17 +9,23 @@ module Rack # The content type of the "uploaded" file attr_accessor :content_type - def initialize(path, content_type = "text/plain", binary = false) - raise "#{path} file does not exist" unless ::File.exist?(path) + def initialize(filepath = nil, ct = "text/plain", bin = false, + path: filepath, content_type: ct, binary: bin, filename: nil, io: nil) + if io + @tempfile = io + @original_filename = filename + else + raise "#{path} file does not exist" unless ::File.exist?(path) + @original_filename = filename || ::File.basename(path) + @tempfile = Tempfile.new([@original_filename, ::File.extname(path)], encoding: Encoding::BINARY) + @tempfile.binmode if binary + FileUtils.copy_file(path, @tempfile.path) + end @content_type = content_type - @original_filename = ::File.basename(path) - @tempfile = Tempfile.new([@original_filename, ::File.extname(path)], encoding: Encoding::BINARY) - @tempfile.binmode if binary - FileUtils.copy_file(path, @tempfile.path) end def path - @tempfile.path + @tempfile.path if @tempfile.respond_to?(:path) end alias_method :local_path, :path diff --git a/lib/rack/query_parser.rb b/lib/rack/query_parser.rb index 2a4eb24496d71c5b0e29c3614158826e1368c3df..1c3923c32ff794175c165b3cf2cfc578638ca567 100644 --- a/lib/rack/query_parser.rb +++ b/lib/rack/query_parser.rb @@ -1,10 +1,8 @@ # frozen_string_literal: true -require_relative 'core_ext/regexp' - module Rack class QueryParser - using ::Rack::RegexpExtensions + (require_relative 'core_ext/regexp'; using ::Rack::RegexpExtensions) if RUBY_VERSION < '2.4' DEFAULT_SEP = /[&;] */n COMMON_SEP = { ";" => /[;] */n, ";," => /[;,] */n, "&" => /[&] */n } @@ -18,6 +16,10 @@ module Rack # sequence. class InvalidParameterError < ArgumentError; end + # ParamsTooDeepError is the error that is raised when params are recursively + # nested over the specified limit. + class ParamsTooDeepError < RangeError; end + def self.make_default(key_space_limit, param_depth_limit) new Params, key_space_limit, param_depth_limit end @@ -64,25 +66,26 @@ module Rack # ParameterTypeError is raised. Users are encouraged to return a 400 in this # case. def parse_nested_query(qs, d = nil) - return {} if qs.nil? || qs.empty? params = make_params - qs.split(d ? (COMMON_SEP[d] || /[#{d}] */n) : DEFAULT_SEP).each do |p| - k, v = p.split('=', 2).map! { |s| unescape(s) } + unless qs.nil? || qs.empty? + (qs || '').split(d ? (COMMON_SEP[d] || /[#{d}] */n) : DEFAULT_SEP).each do |p| + k, v = p.split('=', 2).map! { |s| unescape(s) } - normalize_params(params, k, v, param_depth_limit) + normalize_params(params, k, v, param_depth_limit) + end end return params.to_h rescue ArgumentError => e - raise InvalidParameterError, e.message + raise InvalidParameterError, e.message, e.backtrace end # 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, depth) - raise RangeError if depth <= 0 + raise ParamsTooDeepError if depth <= 0 name =~ %r(\A[\[\]]*([^\[\]]+)\]*) k = $1 || '' @@ -169,7 +172,7 @@ module Rack def []=(key, value) @size += key.size if key && !@params.key?(key) - raise RangeError, 'exceeded available parameter key space' if @size > @limit + raise ParamsTooDeepError, 'exceeded available parameter key space' if @size > @limit @params[key] = value end diff --git a/lib/rack/recursive.rb b/lib/rack/recursive.rb index 6a94ca83d39bcfaf1bcdb39cdf0d4431a76ff763..6971cbfd69dd242ba5b0bb8156029057ffd26488 100644 --- a/lib/rack/recursive.rb +++ b/lib/rack/recursive.rb @@ -19,7 +19,7 @@ module Rack @env[PATH_INFO] = @url.path @env[QUERY_STRING] = @url.query if @url.query @env[HTTP_HOST] = @url.host if @url.host - @env["HTTP_PORT"] = @url.port if @url.port + @env[HTTP_PORT] = @url.port if @url.port @env[RACK_URL_SCHEME] = @url.scheme if @url.scheme super "forwarding to #{url}" diff --git a/lib/rack/reloader.rb b/lib/rack/reloader.rb index e23ed1fbea7a31f1b28a2ede83fc5d9ea20942d8..2f17f50b836099a44ba36b398edd82b60e60ba74 100644 --- a/lib/rack/reloader.rb +++ b/lib/rack/reloader.rb @@ -6,8 +6,6 @@ require 'pathname' -require_relative 'core_ext/regexp' - module Rack # High performant source reloader @@ -24,7 +22,7 @@ module Rack # It is performing a check/reload cycle at the start of every request, but # also respects a cool down time, during which nothing will be done. class Reloader - using ::Rack::RegexpExtensions + (require_relative 'core_ext/regexp'; using ::Rack::RegexpExtensions) if RUBY_VERSION < '2.4' def initialize(app, cooldown = 10, backend = Stat) @app = app diff --git a/lib/rack/request.rb b/lib/rack/request.rb index 54ea86c4f6878d051ce56a6bfdc809cbfd2c80ae..fea984590be4f5f1d0ad047871ccb7f9a8df899b 100644 --- a/lib/rack/request.rb +++ b/lib/rack/request.rb @@ -1,10 +1,5 @@ # frozen_string_literal: true -require 'rack/utils' -require 'rack/media_type' - -require_relative 'core_ext/regexp' - module Rack # Rack::Request provides a convenient interface to a Rack # environment. It is stateless, the environment +env+ passed to the @@ -15,7 +10,7 @@ module Rack # req.params["data"] class Request - using ::Rack::RegexpExtensions + (require_relative 'core_ext/regexp'; using ::Rack::RegexpExtensions) if RUBY_VERSION < '2.4' class << self attr_accessor :ip_filter @@ -93,7 +88,7 @@ module Rack # assert_equal 'image/png,*/*', request.get_header('Accept') # # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 - def add_header key, v + def add_header(key, v) if v.nil? get_header key elsif has_header? key @@ -134,11 +129,23 @@ module Rack # to include the port in a generated URI. DEFAULT_PORTS = { 'http' => 80, 'https' => 443, 'coffee' => 80 } + # The address of the client which connected to the proxy. + HTTP_X_FORWARDED_FOR = 'HTTP_X_FORWARDED_FOR' + + # The contents of the host/:authority header sent to the proxy. + HTTP_X_FORWARDED_HOST = 'HTTP_X_FORWARDED_HOST' + + # The value of the scheme sent to the proxy. HTTP_X_FORWARDED_SCHEME = 'HTTP_X_FORWARDED_SCHEME' - HTTP_X_FORWARDED_PROTO = 'HTTP_X_FORWARDED_PROTO' - HTTP_X_FORWARDED_HOST = 'HTTP_X_FORWARDED_HOST' - HTTP_X_FORWARDED_PORT = 'HTTP_X_FORWARDED_PORT' - HTTP_X_FORWARDED_SSL = 'HTTP_X_FORWARDED_SSL' + + # The protocol used to connect to the proxy. + HTTP_X_FORWARDED_PROTO = 'HTTP_X_FORWARDED_PROTO' + + # The port used to connect to the proxy. + HTTP_X_FORWARDED_PORT = 'HTTP_X_FORWARDED_PORT' + + # Another way for specifing https scheme was used. + HTTP_X_FORWARDED_SSL = 'HTTP_X_FORWARDED_SSL' def body; get_header(RACK_INPUT) end def script_name; get_header(SCRIPT_NAME).to_s end @@ -212,19 +219,52 @@ module Rack end end + # The authority of the incoming request as defined by RFC3976. + # https://tools.ietf.org/html/rfc3986#section-3.2 + # + # In HTTP/1, this is the `host` header. + # In HTTP/2, this is the `:authority` pseudo-header. def authority - get_header(SERVER_NAME) + ':' + get_header(SERVER_PORT) + forwarded_authority || host_authority || server_authority + end + + # The authority as defined by the `SERVER_NAME` and `SERVER_PORT` + # variables. + def server_authority + host = self.server_name + port = self.server_port + + if host + if port + "#{host}:#{port}" + else + host + end + end + end + + def server_name + get_header(SERVER_NAME) + end + + def server_port + if port = get_header(SERVER_PORT) + Integer(port) + end end def cookies - hash = fetch_header(RACK_REQUEST_COOKIE_HASH) do |k| - set_header(k, {}) + hash = fetch_header(RACK_REQUEST_COOKIE_HASH) do |key| + set_header(key, {}) + end + + string = get_header(HTTP_COOKIE) + + unless string == get_header(RACK_REQUEST_COOKIE_STRING) + hash.replace Utils.parse_cookies_header(string) + set_header(RACK_REQUEST_COOKIE_STRING, string) end - string = get_header HTTP_COOKIE - return hash if string == get_header(RACK_REQUEST_COOKIE_STRING) - hash.replace Utils.parse_cookies_header string - set_header(RACK_REQUEST_COOKIE_STRING, string) hash end @@ -237,52 +277,101 @@ module Rack get_header("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest" end - def host_with_port - if forwarded = get_header(HTTP_X_FORWARDED_HOST) - forwarded.split(/,\s?/).last + # The `HTTP_HOST` header. + def host_authority + get_header(HTTP_HOST) + end + + def host_with_port(authority = self.authority) + host, _, port = split_authority(authority) + + if port == DEFAULT_PORTS[self.scheme] + host else - get_header(HTTP_HOST) || "#{get_header(SERVER_NAME) || get_header(SERVER_ADDR)}:#{get_header(SERVER_PORT)}" + authority end end + # Returns a formatted host, suitable for being used in a URI. def host - # Remove port number. - h = host_with_port - if colon_index = h.index(":") - h[0, colon_index] - else - h - end + split_authority(self.authority)[0] + end + + # Returns an address suitable for being to resolve to an address. + # In the case of a domain name or IPv4 address, the result is the same + # as +host+. In the case of IPv6 or future address formats, the square + # brackets are removed. + def hostname + split_authority(self.authority)[1] end def port - if port = extract_port(host_with_port) - port.to_i - elsif port = get_header(HTTP_X_FORWARDED_PORT) - port.to_i - elsif has_header?(HTTP_X_FORWARDED_HOST) - DEFAULT_PORTS[scheme] - elsif has_header?(HTTP_X_FORWARDED_PROTO) - DEFAULT_PORTS[extract_proto_header(get_header(HTTP_X_FORWARDED_PROTO))] - else - get_header(SERVER_PORT).to_i + if authority = self.authority + _, _, port = split_authority(self.authority) + + if port + return port + end + end + + if forwarded_port = self.forwarded_port + return forwarded_port.first + end + + if scheme = self.scheme + if port = DEFAULT_PORTS[self.scheme] + return port + end + end + + self.server_port + end + + def forwarded_for + if value = get_header(HTTP_X_FORWARDED_FOR) + split_header(value).map do |authority| + split_authority(wrap_ipv6(authority))[1] + end + end + end + + def forwarded_port + if value = get_header(HTTP_X_FORWARDED_PORT) + split_header(value).map(&:to_i) + end + end + + def forwarded_authority + if value = get_header(HTTP_X_FORWARDED_HOST) + wrap_ipv6(split_header(value).first) end end def ssl? - scheme == 'https' + scheme == 'https' || scheme == 'wss' end def ip - remote_addrs = split_ip_addresses(get_header('REMOTE_ADDR')) - remote_addrs = reject_trusted_ip_addresses(remote_addrs) + remote_addresses = split_header(get_header('REMOTE_ADDR')) + external_addresses = reject_trusted_ip_addresses(remote_addresses) - return remote_addrs.first if remote_addrs.any? + unless external_addresses.empty? + return external_addresses.first + end - forwarded_ips = split_ip_addresses(get_header('HTTP_X_FORWARDED_FOR')) - .map { |ip| strip_port(ip) } + if forwarded_for = self.forwarded_for + unless forwarded_for.empty? + # The forwarded for addresses are ordered: client, proxy1, proxy2. + # So we reject all the trusted addresses (proxy*) and return the + # last client. Or if we trust everyone, we just return the first + # address. + return reject_trusted_ip_addresses(forwarded_for).last || forwarded_for.first + end + end - return reject_trusted_ip_addresses(forwarded_ips).last || forwarded_ips.first || get_header("REMOTE_ADDR") + # If all the addresses are trusted, and we aren't forwarded, just return + # the first remote address, which represents the source of the request. + remote_addresses.first end # The media type (type/subtype) portion of the CONTENT_TYPE header @@ -323,6 +412,7 @@ module Rack def form_data? type = media_type meth = get_header(RACK_METHODOVERRIDE_ORIGINAL_METHOD) || get_header(REQUEST_METHOD) + (meth == POST && type.nil?) || FORM_DATA_MEDIA_TYPES.include?(type) end @@ -377,8 +467,6 @@ module Rack # Note that modifications will not be persisted in the env. Use update_param or delete_param if you want to destructively modify params. def params self.GET.merge(self.POST) - rescue EOFError - self.GET.dup end # Destructively update a parameter, whether it's in GET and/or POST. Returns nil. @@ -412,9 +500,7 @@ module Rack end def base_url - url = "#{scheme}://#{host}" - url = "#{url}:#{port}" if port != DEFAULT_PORTS[scheme] - url + "#{scheme}://#{host_with_port}" end # Tries to return a remake of the original request URL as a string. @@ -471,9 +557,23 @@ module Rack def default_session; {}; end + # Assist with compatibility when processing `X-Forwarded-For`. + def wrap_ipv6(host) + # Even thought IPv6 addresses should be wrapped in square brackets, + # sometimes this is not done in various legacy/underspecified headers. + # So we try to fix this situation for compatibility reasons. + + # Try to detect IPv6 addresses which aren't escaped yet: + if !host.start_with?('[') && host.count(':') > 1 + "[#{host}]" + else + host + end + end + def parse_http_accept_header(header) - header.to_s.split(/\s*,\s*/).map do |part| - attribute, parameters = part.split(/\s*;\s*/, 2) + header.to_s.split(",").each(&:strip!).map do |part| + attribute, parameters = part.split(";", 2).each(&:strip!) quality = 1.0 if parameters and /\Aq=([\d.]+)/ =~ parameters quality = $1.to_f @@ -494,27 +594,39 @@ module Rack Rack::Multipart.extract_multipart(self, query_parser) end - def split_ip_addresses(ip_addresses) - ip_addresses ? ip_addresses.strip.split(/[,\s]+/) : [] - end - - def strip_port(ip_address) - # IPv6 format with optional port: "[2001:db8:cafe::17]:47011" - # returns: "2001:db8:cafe::17" - sep_start = ip_address.index('[') - sep_end = ip_address.index(']') - if (sep_start && sep_end) - return ip_address[sep_start + 1, sep_end - 1] - end - - # IPv4 format with optional port: "192.0.2.43:47011" - # returns: "192.0.2.43" - sep = ip_address.index(':') - if (sep && ip_address.count(':') == 1) - return ip_address[0, sep] + def split_header(value) + value ? value.strip.split(/[,\s]+/) : [] + end + + AUTHORITY = /^ + # The host: + (?<host> + # An IPv6 address: + (\[(?<ip6>.*)\]) + | + # An IPv4 address: + (?<ip4>[\d\.]+) + | + # A hostname: + (?<name>[a-zA-Z0-9\.\-]+) + ) + # The optional port: + (:(?<port>\d+))? + $/x + + private_constant :AUTHORITY + + def split_authority(authority) + if match = AUTHORITY.match(authority) + if address = match[:ip6] + return match[:host], address, match[:port]&.to_i + else + return match[:host], match[:host], match[:port]&.to_i + end end - ip_address + # Give up! + return authority, authority, nil end def reject_trusted_ip_addresses(ip_addresses) @@ -539,12 +651,6 @@ module Rack end end end - - def extract_port(uri) - if (colon_index = uri.index(':')) - uri[colon_index + 1, uri.length] - end - end end include Env diff --git a/lib/rack/response.rb b/lib/rack/response.rb index 5c8dbab96f45de80e215afee5ef55d1bfb399e5f..fd6d2f5d5555064f819b5babf4a3f236a1c23b89 100644 --- a/lib/rack/response.rb +++ b/lib/rack/response.rb @@ -1,9 +1,5 @@ # frozen_string_literal: true -require 'rack/request' -require 'rack/utils' -require 'rack/body_proxy' -require 'rack/media_type' require 'time' module Rack @@ -19,34 +15,51 @@ module Rack # +write+ are synchronous with the Rack response. # # Your application's +call+ should end returning Response#finish. - class Response - attr_accessor :length, :status, :body - attr_reader :header - alias headers header + def self.[](status, headers, body) + self.new(body, status, headers) + end CHUNKED = 'chunked' STATUS_WITH_NO_ENTITY_BODY = Utils::STATUS_WITH_NO_ENTITY_BODY - def initialize(body = nil, status = 200, header = {}) + attr_accessor :length, :status, :body + attr_reader :headers + + # @deprecated Use {#headers} instead. + alias header headers + + # Initialize the response object with the specified body, status + # and headers. + # + # @param body [nil, #each, #to_str] the response body. + # @param status [Integer] the integer status as defined by the + # HTTP protocol RFCs. + # @param headers [#each] a list of key-value header pairs which + # conform to the HTTP protocol RFCs. + # + # Providing a body which responds to #to_str is legacy behaviour. + def initialize(body = nil, status = 200, headers = {}) @status = status.to_i - @header = Utils::HeaderHash.new(header) + @headers = Utils::HeaderHash[headers] @writer = self.method(:append) @block = nil - @length = 0 # Keep track of whether we have expanded the user supplied body. if body.nil? @body = [] @buffered = true + @length = 0 elsif body.respond_to?(:to_str) @body = [body] @buffered = true + @length = body.to_str.bytesize else @body = body @buffered = false + @length = 0 end yield self if block_given? @@ -61,18 +74,21 @@ module Rack CHUNKED == get_header(TRANSFER_ENCODING) end + # Generate a response array consistent with the requirements of the SPEC. + # @return [Array] a 3-tuple suitable of `[status, headers, body]` + # which is suitable to be returned from the middleware `#call(env)` method. def finish(&block) if STATUS_WITH_NO_ENTITY_BODY[status.to_i] delete_header CONTENT_TYPE delete_header CONTENT_LENGTH close - [status.to_i, header, []] + return [@status, @headers, []] else if block_given? @block = block - [status.to_i, header, self] + return [@status, @headers, self] else - [status.to_i, header, @body] + return [@status, @headers, @body] end end end @@ -152,7 +168,7 @@ module Rack # assert_equal 'Accept-Encoding,Cookie', response.get_header('Vary') # # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 - def add_header key, v + def add_header(key, v) if v.nil? get_header key elsif has_header? key @@ -162,10 +178,16 @@ module Rack end end + # Get the content type of the response. def content_type get_header CONTENT_TYPE end + # Set the content type of the response. + def content_type=(content_type) + set_header CONTENT_TYPE, content_type + end + def media_type MediaType.type(content_type) end @@ -200,7 +222,7 @@ module Rack get_header SET_COOKIE end - def set_cookie_header= v + def set_cookie_header=(v) set_header SET_COOKIE, v end @@ -208,15 +230,31 @@ module Rack get_header CACHE_CONTROL end - def cache_control= v + def cache_control=(v) set_header CACHE_CONTROL, v end + # Specifies that the content shouldn't be cached. Overrides `cache!` if already called. + def do_not_cache! + set_header CACHE_CONTROL, "no-cache, must-revalidate" + set_header EXPIRES, Time.now.httpdate + end + + # Specify that the content should be cached. + # @param duration [Integer] The number of seconds until the cache expires. + # @option directive [String] The cache control directive, one of "public", "private", "no-cache" or "no-store". + def cache!(duration = 3600, directive: "public") + unless headers[CACHE_CONTROL] =~ /no-cache/ + set_header CACHE_CONTROL, "#{directive}, max-age=#{duration}" + set_header EXPIRES, (Time.now + duration).httpdate + end + end + def etag get_header ETAG end - def etag= v + def etag=(v) set_header ETAG, v end @@ -228,6 +266,9 @@ module Rack if @body.is_a?(Array) # The user supplied body was an array: @body = @body.compact + @body.each do |part| + @length += part.to_s.bytesize + end else # Turn the user supplied body into a buffered array: body = @body @@ -236,6 +277,8 @@ module Rack body.each do |part| @writer.call(part.to_s) end + + body.close if body.respond_to?(:close) end @buffered = true @@ -261,7 +304,7 @@ module Rack attr_reader :headers attr_accessor :status - def initialize status, headers + def initialize(status, headers) @status = status @headers = headers end diff --git a/lib/rack/rewindable_input.rb b/lib/rack/rewindable_input.rb index 352bbeaa30f429bc200c68106aa6d09a5c04b358..91b9d1eb367e8758477fa570a24b215f3696445f 100644 --- a/lib/rack/rewindable_input.rb +++ b/lib/rack/rewindable_input.rb @@ -2,7 +2,6 @@ # frozen_string_literal: true require 'tempfile' -require 'rack/utils' module Rack # Class which can make any IO object rewindable, including non-rewindable ones. It does diff --git a/lib/rack/runtime.rb b/lib/rack/runtime.rb index d2bca9e5e4992eb0088769c685d9a12a5f63f4ac..d9b2d8ed19982d6fb9be36aaadc062c4dd6a3616 100644 --- a/lib/rack/runtime.rb +++ b/lib/rack/runtime.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'rack/utils' - module Rack # Sets an "X-Runtime" response header, indicating the response # time of the request, in seconds @@ -22,9 +20,11 @@ module Rack def call(env) start_time = Utils.clock_time status, headers, body = @app.call(env) + headers = Utils::HeaderHash[headers] + request_time = Utils.clock_time - start_time - unless headers.has_key?(@header_name) + unless headers.key?(@header_name) headers[@header_name] = FORMAT_STRING % request_time end diff --git a/lib/rack/sendfile.rb b/lib/rack/sendfile.rb index 3774b26067a420cab846a35fe3bab18320a9f3db..3d5e786ff76850438a4f2f9b2a4d9f17cdb34266 100644 --- a/lib/rack/sendfile.rb +++ b/lib/rack/sendfile.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require 'rack/files' -require 'rack/body_proxy' - module Rack # = Sendfile diff --git a/lib/rack/server.rb b/lib/rack/server.rb index 6137f043b3c312f99d2e62e48ff33928e4190c18..c1f2f5caa321fc9bc4c06d8f1643c81db11445fb 100644 --- a/lib/rack/server.rb +++ b/lib/rack/server.rb @@ -3,12 +3,10 @@ require 'optparse' require 'fileutils' -require_relative 'core_ext/regexp' - module Rack class Server - using ::Rack::RegexpExtensions + (require_relative 'core_ext/regexp'; using ::Rack::RegexpExtensions) if RUBY_VERSION < '2.4' class Options def parse!(args) @@ -42,7 +40,7 @@ module Rack opts.on("-r", "--require LIBRARY", "require the library, before executing your script") { |library| - options[:require] = library + (options[:require] ||= []) << library } opts.separator "" @@ -143,7 +141,7 @@ module Rack return "" if !has_options end info.join("\n") - rescue NameError + rescue NameError, LoadError return "Warning: Could not find handler specified (#{options[:server] || 'default'}) to determine handler-specific options" end end @@ -285,7 +283,7 @@ module Rack self.class.middleware end - def start &blk + def start(&block) if options[:warn] $-w = true end @@ -294,7 +292,7 @@ module Rack $LOAD_PATH.unshift(*includes) end - if library = options[:require] + Array(options[:require]).each do |library| require library end @@ -326,7 +324,7 @@ module Rack end end - server.run wrapped_app, options, &blk + server.run(wrapped_app, **options, &block) end def server @@ -425,7 +423,10 @@ module Rack end def daemonize_app + # Cannot be covered as it forks + # :nocov: Process.daemon + # :nocov: end def write_pid diff --git a/lib/rack/session/abstract/id.rb b/lib/rack/session/abstract/id.rb index 20ef8b833e93d40f94dfffc6f00e1a9cb8c9aeb1..638bd3b3b08eea45c44de5aaca0fbdba4b47f12a 100644 --- a/lib/rack/session/abstract/id.rb +++ b/lib/rack/session/abstract/id.rb @@ -3,10 +3,8 @@ # AUTHOR: blink <blinketje@gmail.com>; blink#ruby-lang@irc.freenode.net # bugrep: Andreas Zehnder -require 'rack' +require_relative '../../../rack' require 'time' -require 'rack/request' -require 'rack/response' require 'securerandom' require 'digest/sha2' @@ -44,18 +42,6 @@ module Rack # SessionHash is responsible to lazily load the session from store. class SessionHash - using Module.new { - refine Hash do - def transform_keys(&block) - hash = {} - each do |key, value| - hash[block.call(key)] = value - end - hash - end - end - } unless {}.respond_to?(:transform_keys) - include Enumerable attr_writer :id @@ -98,6 +84,11 @@ module Rack @data[key.to_s] end + def dig(key, *keys) + load_for_read! + @data.dig(key.to_s, *keys) + end + def fetch(key, default = Unspecified, &block) load_for_read! if default == Unspecified @@ -201,14 +192,19 @@ module Rack end def stringify_keys(other) - other.to_hash.transform_keys(&:to_s) + # Use transform_keys after dropping Ruby 2.4 support + hash = {} + other.to_hash.each do |key, value| + hash[key.to_s] = value + end + hash end end # ID sets up a basic framework for implementing an id based sessioning # service. Cookies sent to the client for maintaining sessions will only - # contain an id reference. Only #find_session and #write_session are - # required to be overwritten. + # contain an id reference. Only #find_session, #write_session and + # #delete_session are required to be overwritten. # # All parameters are optional. # * :key determines the name of the cookie, by default it is @@ -256,6 +252,7 @@ module Rack @default_options = self.class::DEFAULT_OPTIONS.merge(options) @key = @default_options.delete(:key) @cookie_only = @default_options.delete(:cookie_only) + @same_site = @default_options.delete(:same_site) initialize_sid end @@ -397,6 +394,12 @@ module Rack cookie[:value] = cookie_value(data) cookie[:expires] = Time.now + options[:expire_after] if options[:expire_after] cookie[:expires] = Time.now + options[:max_age] if options[:max_age] + + if @same_site.respond_to? :call + cookie[:same_site] = @same_site.call(req, res) + else + cookie[:same_site] = @same_site + end set_cookie(req, res, cookie.merge!(options)) end end diff --git a/lib/rack/session/cookie.rb b/lib/rack/session/cookie.rb index d110aee249ecec9d40e862e7a7e65185c7cd8899..bb541396f7b0ef9f8b57dee2446a28646a1b9c07 100644 --- a/lib/rack/session/cookie.rb +++ b/lib/rack/session/cookie.rb @@ -2,9 +2,7 @@ require 'openssl' require 'zlib' -require 'rack/request' -require 'rack/response' -require 'rack/session/abstract/id' +require_relative 'abstract/id' require 'json' require 'base64' diff --git a/lib/rack/session/pool.rb b/lib/rack/session/pool.rb index f5b6265046f92d3a84c29a6b63e01643c89121ac..4885605f5d294ea867164758c206c00cccce592c 100644 --- a/lib/rack/session/pool.rb +++ b/lib/rack/session/pool.rb @@ -5,7 +5,7 @@ # apeiros, for session id generation, expiry setup, and threadiness # sergio, threadiness and bugreps -require 'rack/session/abstract/id' +require_relative 'abstract/id' require 'thread' module Rack diff --git a/lib/rack/show_exceptions.rb b/lib/rack/show_exceptions.rb index 843af607afb717eab3445f3740d7b65909a4ef2b..07e60388069f7926eb98c91e310217b5acaa0fb9 100644 --- a/lib/rack/show_exceptions.rb +++ b/lib/rack/show_exceptions.rb @@ -2,8 +2,6 @@ require 'ostruct' require 'erb' -require 'rack/request' -require 'rack/utils' module Rack # Rack::ShowExceptions catches all exceptions raised from the app it @@ -65,12 +63,12 @@ module Rack def pretty(env, exception) req = Rack::Request.new(env) - # This double assignment is to prevent an "unused variable" warning on - # Ruby 1.9.3. Yes, it is dumb, but I don't like Ruby yelling at me. + # This double assignment is to prevent an "unused variable" warning. + # Yes, it is dumb, but I don't like Ruby yelling at me. path = path = (req.script_name + req.path_info).squeeze("/") - # This double assignment is to prevent an "unused variable" warning on - # Ruby 1.9.3. Yes, it is dumb, but I don't like Ruby yelling at me. + # This double assignment is to prevent an "unused variable" warning. + # Yes, it is dumb, but I don't like Ruby yelling at me. frames = frames = exception.backtrace.map { |line| frame = OpenStruct.new if line =~ /(.*?):(\d+)(:in `(.*)')?/ @@ -313,7 +311,7 @@ module Rack <% end %> <h3 id="post-info">POST</h3> - <% if req.POST and not req.POST.empty? %> + <% if ((req.POST and not req.POST.empty?) rescue (no_post_data = "Invalid POST data"; nil)) %> <table class="req"> <thead> <tr> @@ -331,7 +329,7 @@ module Rack </tbody> </table> <% else %> - <p>No POST data.</p> + <p><%= no_post_data || "No POST data" %>.</p> <% end %> diff --git a/lib/rack/show_status.rb b/lib/rack/show_status.rb index 3fdfca5e6f3b2109387b68131eb865f00ab80e54..a99bdaf33aa7e5ec388444f50e90ba32aaf0f71a 100644 --- a/lib/rack/show_status.rb +++ b/lib/rack/show_status.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true require 'erb' -require 'rack/request' -require 'rack/utils' module Rack # Rack::ShowStatus catches all empty responses and replaces them @@ -20,19 +18,19 @@ module Rack def call(env) status, headers, body = @app.call(env) - headers = Utils::HeaderHash.new(headers) + headers = Utils::HeaderHash[headers] empty = headers[CONTENT_LENGTH].to_i <= 0 # client or server error, or explicit message if (status.to_i >= 400 && empty) || env[RACK_SHOWSTATUS_DETAIL] - # This double assignment is to prevent an "unused variable" warning on - # Ruby 1.9.3. Yes, it is dumb, but I don't like Ruby yelling at me. + # This double assignment is to prevent an "unused variable" warning. + # Yes, it is dumb, but I don't like Ruby yelling at me. req = req = Rack::Request.new(env) message = Rack::Utils::HTTP_STATUS_CODES[status.to_i] || status.to_s - # This double assignment is to prevent an "unused variable" warning on - # Ruby 1.9.3. Yes, it is dumb, but I don't like Ruby yelling at me. + # This double assignment is to prevent an "unused variable" warning. + # Yes, it is dumb, but I don't like Ruby yelling at me. detail = detail = env[RACK_SHOWSTATUS_DETAIL] || message body = @template.result(binding) diff --git a/lib/rack/static.rb b/lib/rack/static.rb index 9a0017db942d03ce1ebbc2fb7eb6b33c555e5b34..8cb58b2fd7342fe530ff7e4508cf132fb2abdf00 100644 --- a/lib/rack/static.rb +++ b/lib/rack/static.rb @@ -1,10 +1,5 @@ # frozen_string_literal: true -require "rack/files" -require "rack/utils" - -require_relative 'core_ext/regexp' - module Rack # The Rack::Static middleware intercepts requests for static files @@ -19,6 +14,11 @@ module Rack # # use Rack::Static, :urls => ["/media"] # + # Same as previous, but instead of returning 404 for missing files under + # /media, call the next middleware: + # + # use Rack::Static, :urls => ["/media"], :cascade => true + # # Serve all requests beginning with /css or /images from the folder "public" # in the current directory (ie public/css/* and public/images/*): # @@ -86,13 +86,14 @@ module Rack # ] # class Static - using ::Rack::RegexpExtensions + (require_relative 'core_ext/regexp'; using ::Rack::RegexpExtensions) if RUBY_VERSION < '2.4' def initialize(app, options = {}) @app = app @urls = options[:urls] || ["/favicon.ico"] @index = options[:index] @gzip = options[:gzip] + @cascade = options[:cascade] root = options[:root] || Dir.pwd # HTTP Headers @@ -133,6 +134,8 @@ module Rack if response[0] == 404 response = nil + elsif response[0] == 304 + # Do nothing, leave headers as is else if mime_type = Mime.mime_type(::File.extname(path), 'text/plain') response[1][CONTENT_TYPE] = mime_type @@ -144,6 +147,10 @@ module Rack path = env[PATH_INFO] response ||= @file_server.call(env) + if @cascade && response[0] == 404 + return @app.call(env) + end + headers = response[1] applicable_rules(path).each do |rule, new_headers| new_headers.each { |field, content| headers[field] = content } diff --git a/lib/rack/tempfile_reaper.rb b/lib/rack/tempfile_reaper.rb index 73b6c1c8df2d2b76793b6dd24f0c13fdea6d9e36..9b04fefc2441402ea1862a1780bc7611ffd27fa7 100644 --- a/lib/rack/tempfile_reaper.rb +++ b/lib/rack/tempfile_reaper.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'rack/body_proxy' - module Rack # Middleware tracks and cleans Tempfiles created throughout a request (i.e. Rack::Multipart) diff --git a/lib/rack/urlmap.rb b/lib/rack/urlmap.rb index c5d9c44f5392d7620e5166080d6c71dfb1b89892..8462f92067d4c3397378cff6766d21b2ac5a9123 100644 --- a/lib/rack/urlmap.rb +++ b/lib/rack/urlmap.rb @@ -16,9 +16,6 @@ module Rack # first, since they are most specific. class URLMap - NEGATIVE_INFINITY = -1.0 / 0.0 - INFINITY = 1.0 / 0.0 - def initialize(map = {}) remap(map) end @@ -38,11 +35,11 @@ module Rack end location = location.chomp('/') - match = Regexp.new("^#{Regexp.quote(location).gsub('/', '/+')}(.*)", nil, 'n') + match = Regexp.new("^#{Regexp.quote(location).gsub('/', '/+')}(.*)", Regexp::NOENCODING) [host, location, match, app] }.sort_by do |(host, location, _, _)| - [host ? -host.size : INFINITY, -location.size] + [host ? -host.size : Float::INFINITY, -location.size] end end diff --git a/lib/rack/utils.rb b/lib/rack/utils.rb index 492f9bcfe245b52a25383bedd38dd351faec4a89..c8e61ea1806c615bfdad47f0f7c14916d6b60694 100644 --- a/lib/rack/utils.rb +++ b/lib/rack/utils.rb @@ -5,17 +5,16 @@ require 'uri' require 'fileutils' require 'set' require 'tempfile' -require 'rack/query_parser' require 'time' -require_relative 'core_ext/regexp' +require_relative 'query_parser' module Rack # Rack::Utils contains a grab-bag of useful methods for writing web # applications adopted from all kinds of Ruby libraries. module Utils - using ::Rack::RegexpExtensions + (require_relative 'core_ext/regexp'; using ::Rack::RegexpExtensions) if RUBY_VERSION < '2.4' ParameterTypeError = QueryParser::ParameterTypeError InvalidParameterError = QueryParser::InvalidParameterError @@ -23,6 +22,9 @@ module Rack COMMON_SEP = QueryParser::COMMON_SEP KeySpaceConstrainedParams = QueryParser::Params + RFC2822_DAY_NAME = [ 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' ] + RFC2822_MONTH_NAME = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ] + class << self attr_accessor :default_query_parser end @@ -30,42 +32,50 @@ module Rack # This helps prevent a rogue client from flooding a Request. self.default_query_parser = QueryParser.make_default(65536, 100) + module_function + # URI escapes. (CGI style space to +) def escape(s) URI.encode_www_form_component(s) end - module_function :escape # Like URI escaping, but with %20 instead of +. Strictly speaking this is # true URI escaping. def escape_path(s) ::URI::DEFAULT_PARSER.escape s end - module_function :escape_path # Unescapes the **path** component of a URI. See Rack::Utils.unescape for # unescaping query parameters or form components. def unescape_path(s) ::URI::DEFAULT_PARSER.unescape s end - module_function :unescape_path - # Unescapes a URI escaped string with +encoding+. +encoding+ will be the # target encoding of the string returned, and it defaults to UTF-8 def unescape(s, encoding = Encoding::UTF_8) URI.decode_www_form_component(s, encoding) end - module_function :unescape class << self - attr_accessor :multipart_part_limit + attr_accessor :multipart_total_part_limit + + attr_accessor :multipart_file_limit + + # multipart_part_limit is the original name of multipart_file_limit, but + # the limit only counts parts with filenames. + alias multipart_part_limit multipart_file_limit + alias multipart_part_limit= multipart_file_limit= end - # The maximum number of parts a request can contain. Accepting too many part - # can lead to the server running out of file handles. + # The maximum number of file parts a request can contain. Accepting too + # many parts can lead to the server running out of file handles. # Set to `0` for no limit. - self.multipart_part_limit = (ENV['RACK_MULTIPART_PART_LIMIT'] || 128).to_i + self.multipart_file_limit = (ENV['RACK_MULTIPART_PART_LIMIT'] || ENV['RACK_MULTIPART_FILE_LIMIT'] || 128).to_i + + # The maximum total number of parts a request can contain. Accepting too + # many can lead to excessive memory use and parsing time. + self.multipart_total_part_limit = (ENV['RACK_MULTIPART_TOTAL_PART_LIMIT'] || 4096).to_i def self.param_depth_limit default_query_parser.param_depth_limit @@ -88,21 +98,20 @@ module Rack Process.clock_gettime(Process::CLOCK_MONOTONIC) end else + # :nocov: def clock_time Time.now.to_f end + # :nocov: end - module_function :clock_time def parse_query(qs, d = nil, &unescaper) Rack::Utils.default_query_parser.parse_query(qs, d, &unescaper) end - module_function :parse_query def parse_nested_query(qs, d = nil) Rack::Utils.default_query_parser.parse_nested_query(qs, d) end - module_function :parse_nested_query def build_query(params) params.map { |k, v| @@ -113,7 +122,6 @@ module Rack end }.join("&") end - module_function :build_query def build_nested_query(value, prefix = nil) case value @@ -132,7 +140,6 @@ module Rack "#{prefix}=#{escape(value)}" end end - module_function :build_nested_query def q_values(q_value_header) q_value_header.to_s.split(/\s*,\s*/).map do |part| @@ -144,8 +151,11 @@ module Rack [value, quality] end end - module_function :q_values + # Return best accept value to use, based on the algorithm + # in RFC 2616 Section 14. If there are multiple best + # matches (same specificity and quality), the value returned + # is arbitrary. def best_q_match(q_value_header, available_mimes) values = q_values(q_value_header) @@ -158,7 +168,6 @@ module Rack end.last matches && matches.first end - module_function :best_q_match ESCAPE_HTML = { "&" => "&", @@ -175,22 +184,27 @@ module Rack def escape_html(string) string.to_s.gsub(ESCAPE_HTML_PATTERN){|c| ESCAPE_HTML[c] } end - module_function :escape_html def select_best_encoding(available_encodings, accept_encoding) # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html - expanded_accept_encoding = - accept_encoding.each_with_object([]) do |(m, q), list| - if m == "*" - (available_encodings - accept_encoding.map(&:first)) - .each { |m2| list << [m2, q] } - else - list << [m, q] + expanded_accept_encoding = [] + + accept_encoding.each do |m, q| + preference = available_encodings.index(m) || available_encodings.size + + if m == "*" + (available_encodings - accept_encoding.map(&:first)).each do |m2| + expanded_accept_encoding << [m2, q, preference] end + else + expanded_accept_encoding << [m, q, preference] end + end - encoding_candidates = expanded_accept_encoding.sort_by { |_, q| -q }.map!(&:first) + encoding_candidates = expanded_accept_encoding + .sort_by { |_, q, p| [-q, p] } + .map!(&:first) unless encoding_candidates.include?("identity") encoding_candidates.push("identity") @@ -202,27 +216,23 @@ module Rack (encoding_candidates & available_encodings)[0] end - module_function :select_best_encoding def parse_cookies(env) parse_cookies_header env[HTTP_COOKIE] end - module_function :parse_cookies def parse_cookies_header(header) - # According to RFC 2109: - # If multiple cookies satisfy the criteria above, they are ordered in - # the Cookie header such that those with more specific Path attributes - # precede those with less specific. Ordering with respect to other - # attributes (e.g., Domain) is unspecified. + # According to RFC 6265: + # The syntax for cookie headers only supports semicolons + # User Agent -> Server == + # Cookie: SID=31d4d96e407aad42; lang=en-US return {} unless header - header.split(/[;,] */n).each_with_object({}) do |cookie, cookies| + header.split(/[;] */n).each_with_object({}) do |cookie, cookies| next if cookie.empty? key, value = cookie.split('=', 2) cookies[key] = (unescape(value) rescue value) unless cookies.key?(key) end end - module_function :parse_cookies_header def add_cookie_to_header(header, key, value) case value @@ -264,13 +274,11 @@ module Rack raise ArgumentError, "Unrecognized cookie header value. Expected String, Array, or nil, got #{header.inspect}" end end - module_function :add_cookie_to_header def set_cookie_header!(header, key, value) header[SET_COOKIE] = add_cookie_to_header(header[SET_COOKIE], key, value) nil end - module_function :set_cookie_header! def make_delete_cookie_header(header, key, value) case header @@ -282,25 +290,30 @@ module Rack cookies = header end - regexp = if value[:domain] - /\A#{escape(key)}=.*domain=#{value[:domain]}/ - elsif value[:path] - /\A#{escape(key)}=.*path=#{value[:path]}/ + key = escape(key) + domain = value[:domain] + path = value[:path] + regexp = if domain + if path + /\A#{key}=.*(?:domain=#{domain}(?:;|$).*path=#{path}(?:;|$)|path=#{path}(?:;|$).*domain=#{domain}(?:;|$))/ + else + /\A#{key}=.*domain=#{domain}(?:;|$)/ + end + elsif path + /\A#{key}=.*path=#{path}(?:;|$)/ else - /\A#{escape(key)}=/ + /\A#{key}=/ end cookies.reject! { |cookie| regexp.match? cookie } cookies.join("\n") end - module_function :make_delete_cookie_header def delete_cookie_header!(header, key, value = {}) header[SET_COOKIE] = add_remove_cookie_to_header(header[SET_COOKIE], key, value) nil end - module_function :delete_cookie_header! # Adds a cookie that will *remove* a cookie from the client. Hence the # strange method name. @@ -313,12 +326,10 @@ module Rack expires: Time.at(0) }.merge(value)) end - module_function :add_remove_cookie_to_header def rfc2822(time) time.rfc2822 end - module_function :rfc2822 # Modified version of stdlib time.rb Time#rfc2822 to use '%d-%b-%Y' instead # of '% %b %Y'. @@ -330,11 +341,10 @@ module Rack # weekday and month. # def rfc2109(time) - wday = Time::RFC2822_DAY_NAME[time.wday] - mon = Time::RFC2822_MONTH_NAME[time.mon - 1] + wday = RFC2822_DAY_NAME[time.wday] + mon = RFC2822_MONTH_NAME[time.mon - 1] time.strftime("#{wday}, %d-#{mon}-%Y %H:%M:%S GMT") end - module_function :rfc2109 # Parses the "Range:" header, if present, into an array of Range objects. # Returns nil if the header is missing or syntactically invalid. @@ -343,24 +353,24 @@ module Rack warn "`byte_ranges` is deprecated, please use `get_byte_ranges`" if $VERBOSE get_byte_ranges env['HTTP_RANGE'], size end - module_function :byte_ranges def get_byte_ranges(http_range, size) # See <http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35> return nil unless http_range && http_range =~ /bytes=([^;]+)/ ranges = [] $1.split(/,\s*/).each do |range_spec| - return nil unless range_spec =~ /(\d*)-(\d*)/ - r0, r1 = $1, $2 - if r0.empty? - return nil if r1.empty? + return nil unless range_spec.include?('-') + range = range_spec.split('-') + r0, r1 = range[0], range[1] + if r0.nil? || r0.empty? + return nil if r1.nil? # suffix-byte-range-spec, represents trailing suffix of file r0 = size - r1.to_i r0 = 0 if r0 < 0 r1 = size - 1 else r0 = r0.to_i - if r1.empty? + if r1.nil? r1 = size - 1 else r1 = r1.to_i @@ -372,7 +382,6 @@ module Rack end ranges end - module_function :get_byte_ranges # Constant time string comparison. # @@ -389,7 +398,6 @@ module Rack b.each_byte { |v| r |= v ^ l[i += 1] } r == 0 end - module_function :secure_compare # Context allows the use of a compatible middleware at different points # in a request handling stack. A compatible middleware must define @@ -422,6 +430,14 @@ module Rack # # @api private class HeaderHash < Hash # :nodoc: + def self.[](headers) + if headers.is_a?(HeaderHash) && !headers.frozen? + return headers + else + return self.new(headers) + end + end + def initialize(hash = {}) super() @names = {} @@ -434,6 +450,12 @@ module Rack @names = other.names.dup end + # on clear, we need to clear @names hash + def clear + super + @names.clear + end + def each super do |k, v| yield(k, v.respond_to?(:to_ary) ? v.to_ary.join("\n") : v) @@ -578,7 +600,6 @@ module Rack status.to_i end end - module_function :status_code PATH_SEPS = Regexp.union(*[::File::SEPARATOR, ::File::ALT_SEPARATOR].compact) @@ -592,18 +613,16 @@ module Rack part == '..' ? clean.pop : clean << part end - clean.unshift '/' if parts.empty? || parts.first.empty? - - ::File.join clean + clean_path = clean.join(::File::SEPARATOR) + clean_path.prepend("/") if parts.empty? || parts.first.empty? + clean_path end - module_function :clean_path_info NULL_BYTE = "\0" def valid_path?(path) path.valid_encoding? && !path.include?(NULL_BYTE) end - module_function :valid_path? end end diff --git a/lib/rack/version.rb b/lib/rack/version.rb new file mode 100644 index 0000000000000000000000000000000000000000..d451de434c390e2dca840332535bc6aa5dd9e4b6 --- /dev/null +++ b/lib/rack/version.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Copyright (C) 2007-2019 Leah Neukirchen <http://leahneukirchen.org/infopage.html> +# +# Rack is freely distributable under the terms of an MIT-style license. +# See MIT-LICENSE or https://opensource.org/licenses/MIT. + +# The Rack main module, serving as a namespace for all core Rack +# modules and classes. +# +# All modules meant for use in your application are <tt>autoload</tt>ed here, +# so it should be enough just to <tt>require 'rack'</tt> in your code. + +module Rack + # The Rack protocol version number implemented. + VERSION = [1, 3] + + # Return the Rack protocol version as a dotted string. + def self.version + VERSION.join(".") + end + + RELEASE = "2.2.6.4" + + # Return the Rack release as a dotted string. + def self.release + RELEASE + end +end diff --git a/rack.gemspec b/rack.gemspec index f7b13b17074078a4b14c6f3aef7f316283866e97..246ed7c639ba65a21e1ad70620a7e12ab2f49d2d 100644 --- a/rack.gemspec +++ b/rack.gemspec @@ -1,39 +1,41 @@ # frozen_string_literal: true +require_relative 'lib/rack/version' + Gem::Specification.new do |s| s.name = "rack" - s.version = File.read('lib/rack.rb')[/RELEASE += +([\"\'])([\d][\w\.]+)\1/, 2] - s.platform = Gem::Platform::RUBY - s.summary = "a modular Ruby webserver interface" - s.license = "MIT" - - s.description = <<-EOF -Rack provides a minimal, modular and adaptable interface for developing -web applications in Ruby. By wrapping HTTP requests and responses in -the simplest way possible, it unifies and distills the API for web -servers, web frameworks, and software in between (the so-called -middleware) into a single method call. - -Also see https://rack.github.io/. -EOF - - s.files = Dir['{bin/*,contrib/*,example/*,lib/**/*}'] + - %w(MIT-LICENSE rack.gemspec Rakefile README.rdoc SPEC) - s.bindir = 'bin' + s.version = Rack::RELEASE + s.platform = Gem::Platform::RUBY + s.summary = "A modular Ruby webserver interface." + s.license = "MIT" + + s.description = <<~EOF + Rack provides a minimal, modular and adaptable interface for developing + web applications in Ruby. By wrapping HTTP requests and responses in + the simplest way possible, it unifies and distills the API for web + servers, web frameworks, and software in between (the so-called + middleware) into a single method call. + EOF + + s.files = Dir['{bin/*,contrib/*,example/*,lib/**/*}'] + + %w(MIT-LICENSE rack.gemspec Rakefile README.rdoc SPEC.rdoc) + + s.bindir = 'bin' s.executables << 'rackup' s.require_path = 'lib' - s.extra_rdoc_files = ['README.rdoc', 'CHANGELOG.md'] - - s.author = 'Leah Neukirchen' - s.email = 'leah@vuxu.org' - s.homepage = 'https://rack.github.io/' - s.required_ruby_version = '>= 2.2.2' - s.metadata = { - "bug_tracker_uri" => "https://github.com/rack/rack/issues", - "changelog_uri" => "https://github.com/rack/rack/blob/master/CHANGELOG.md", + s.extra_rdoc_files = ['README.rdoc', 'CHANGELOG.md', 'CONTRIBUTING.md'] + + s.author = 'Leah Neukirchen' + s.email = 'leah@vuxu.org' + + s.homepage = 'https://github.com/rack/rack' + + s.required_ruby_version = '>= 2.3.0' + + s.metadata = { + "bug_tracker_uri" => "https://github.com/rack/rack/issues", + "changelog_uri" => "https://github.com/rack/rack/blob/master/CHANGELOG.md", "documentation_uri" => "https://rubydoc.info/github/rack/rack", - "homepage_uri" => "https://rack.github.io", - "mailing_list_uri" => "https://groups.google.com/forum/#!forum/rack-devel", "source_code_uri" => "https://github.com/rack/rack" } diff --git a/test/builder/frozen.ru b/test/builder/frozen.ru new file mode 100644 index 0000000000000000000000000000000000000000..5bad750f4f4250fc5209dfe922525f225df8e03d --- /dev/null +++ b/test/builder/frozen.ru @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +run lambda { |env| + body = 'frozen' + raise "Not frozen!" unless body.frozen? + [200, { 'Content-Type' => 'text/plain' }, [body]] +} diff --git a/test/helper.rb b/test/helper.rb index 38f7df405b8bb84d68be22dfa16696568e908382..55799c8c65b4b92f868480450b2a3a462258a9c3 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -1,8 +1,21 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' +if ENV.delete('COVERAGE') + require 'coverage' + require 'simplecov' -module Rack - class TestCase < Minitest::Test + def SimpleCov.rack_coverage(**opts) + start do + add_filter "/test/" + add_filter "/lib/rack/handler" + add_group('Missing'){|src| src.covered_percent < 100} + add_group('Covered'){|src| src.covered_percent == 100} + end end + SimpleCov.rack_coverage end + +$:.unshift(File.expand_path('../lib', __dir__)) +require_relative '../lib/rack' +require 'minitest/global_expectations/autorun' +require 'stringio' diff --git a/test/load/rack-test-a.rb b/test/load/rack-test-a.rb new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/test/load/rack-test-b.rb b/test/load/rack-test-b.rb new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/test/multipart/content_type_and_no_disposition b/test/multipart/content_type_and_no_disposition new file mode 100644 index 0000000000000000000000000000000000000000..8a07dacdff7762767b53b4b45a0c223d61014007 --- /dev/null +++ b/test/multipart/content_type_and_no_disposition @@ -0,0 +1,5 @@ +--AaB03x +Content-Type: text/plain; charset=US-ASCII + +contents +--AaB03x-- diff --git a/test/multipart/filename_with_escaped_quotes_and_modification_param b/test/multipart/filename_with_escaped_quotes_and_modification_param index 7619bd5074dd184e0c61c934bf2656c68bffb74f..929f6ad3f960bd1b51a1a7bc43beddcdb1d6fcda 100644 --- a/test/multipart/filename_with_escaped_quotes_and_modification_param +++ b/test/multipart/filename_with_escaped_quotes_and_modification_param @@ -1,6 +1,6 @@ --AaB03x Content-Type: image/jpeg -Content-Disposition: attachment; name="files"; filename=""human" genome.jpeg"; modification-date="Wed, 12 Feb 1997 16:29:51 -0500"; +Content-Disposition: attachment; name="files"; filename="\"human\" genome.jpeg"; modification-date="Wed, 12 Feb 1997 16:29:51 -0500"; Content-Description: a complete map of the human genome contents diff --git a/test/psych_fix.rb b/test/psych_fix.rb new file mode 100644 index 0000000000000000000000000000000000000000..ef8a5be3ce401cb183a84bfa9771cf46952de69b --- /dev/null +++ b/test/psych_fix.rb @@ -0,0 +1,8 @@ +# Work correctly with older versions of Psych, having +# unsafe_load call load (in older versions, load operates +# as unsafe_load in current version). +unless YAML.respond_to?(:unsafe_load) + def YAML.unsafe_load(body) + load(body) + end +end diff --git a/test/spec_auth_basic.rb b/test/spec_auth_basic.rb index 3e479ace9b8ae13fec45aef8383d0841fd2e19a2..7d39b195260deeb11828f447c6c4e2d7a6a8d7d7 100644 --- a/test/spec_auth_basic.rb +++ b/test/spec_auth_basic.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/auth/basic' -require 'rack/lint' -require 'rack/mock' +require_relative 'helper' describe Rack::Auth::Basic do def realm @@ -84,6 +81,15 @@ describe Rack::Auth::Basic do end end + it 'return 400 Bad Request for a authorization header with only username' do + auth = 'Basic ' + ['foo'].pack("m*") + request 'HTTP_AUTHORIZATION' => auth do |response| + response.must_be :client_error? + response.status.must_equal 400 + response.wont_include 'WWW-Authenticate' + end + end + it 'takes realm as optional constructor arg' do app = Rack::Auth::Basic.new(unprotected_app, realm) { true } realm.must_equal app.realm diff --git a/test/spec_auth_digest.rb b/test/spec_auth_digest.rb index cc205aa9f0dc8c867ca636ea0907bcb305af903a..6e32152f401c377e1d9c07136e5327039b1a463b 100644 --- a/test/spec_auth_digest.rb +++ b/test/spec_auth_digest.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/auth/digest/md5' -require 'rack/lint' -require 'rack/mock' +require_relative 'helper' describe Rack::Auth::Digest::MD5 do def realm @@ -259,4 +256,18 @@ describe Rack::Auth::Digest::MD5 do app = Rack::Auth::Digest::MD5.new(unprotected_app, realm) { true } realm.must_equal app.realm end + + it 'Request#respond_to? and method_missing work as expected' do + req = Rack::Auth::Digest::Request.new({ 'HTTP_AUTHORIZATION' => 'a=b' }) + req.respond_to?(:banana).must_equal false + req.respond_to?(:nonce).must_equal true + req.respond_to?(:a).must_equal true + req.a.must_equal 'b' + lambda { req.a(2) }.must_raise ArgumentError + end + + it 'Nonce#fresh? should be the opposite of stale?' do + Rack::Auth::Digest::Nonce.new.fresh?.must_equal true + Rack::Auth::Digest::Nonce.new.stale?.must_equal false + end end diff --git a/test/spec_body_proxy.rb b/test/spec_body_proxy.rb index d3853e1e9f269c5a5473fbf1eae594124e2398c5..1199f2f18e42db06a53b943174b05417cf28c24b 100644 --- a/test/spec_body_proxy.rb +++ b/test/spec_body_proxy.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/body_proxy' -require 'stringio' +require_relative 'helper' describe Rack::BodyProxy do it 'call each on the wrapped body' do @@ -58,6 +56,20 @@ describe Rack::BodyProxy do proxy.respond_to?(:foo, false).must_equal false end + it 'allows #method to work with delegated methods' do + body = Object.new + def body.banana; :pear end + proxy = Rack::BodyProxy.new(body) { } + proxy.method(:banana).call.must_equal :pear + end + + it 'allows calling delegated methods with keywords' do + body = Object.new + def body.banana(foo: nil); foo end + proxy = Rack::BodyProxy.new(body) { } + proxy.banana(foo: 1).must_equal 1 + end + it 'not respond to :to_ary' do body = Object.new.tap { |o| def o.to_ary() end } body.respond_to?(:to_ary).must_equal true @@ -80,8 +92,4 @@ describe Rack::BodyProxy do proxy.close closed.must_equal true end - - it 'provide an #each method' do - Rack::BodyProxy.method_defined?(:each).must_equal true - end end diff --git a/test/spec_builder.rb b/test/spec_builder.rb index 06918616dfdb701832668bd8422dface154b07fa..c0f59c1828cb14193b3ecfa3e60b72ca94eeaa8e 100644 --- a/test/spec_builder.rb +++ b/test/spec_builder.rb @@ -1,11 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/builder' -require 'rack/lint' -require 'rack/mock' -require 'rack/show_exceptions' -require 'rack/urlmap' +require_relative 'helper' class NothingMiddleware def initialize(app, **) @@ -43,6 +38,19 @@ describe Rack::Builder do Rack::MockRequest.new(app).get("/sub").body.to_s.must_equal 'sub' end + it "supports use when mapping" do + app = builder_to_app do + map '/sub' do + use Rack::ContentLength + run lambda { |inner_env| [200, { "Content-Type" => "text/plain" }, ['sub']] } + end + use Rack::ContentLength + run lambda { |inner_env| [200, { "Content-Type" => "text/plain" }, ['root']] } + end + Rack::MockRequest.new(app).get("/").headers['Content-Length'].must_equal '4' + Rack::MockRequest.new(app).get("/sub").headers['Content-Length'].must_equal '3' + end + it "doesn't dupe env even when mapping" do app = builder_to_app do use NothingMiddleware, noop: :noop @@ -253,8 +261,23 @@ describe Rack::Builder do end it "strips leading unicode byte order mark when present" do - app, _ = Rack::Builder.parse_file config_file('bom.ru') - Rack::MockRequest.new(app).get("/").body.to_s.must_equal 'OK' + enc = Encoding.default_external + begin + Encoding.default_external = 'UTF-8' + app, _ = Rack::Builder.parse_file config_file('bom.ru') + Rack::MockRequest.new(app).get("/").body.to_s.must_equal 'OK' + ensure + Encoding.default_external = enc + end + end + + it "respects the frozen_string_literal magic comment" do + app, _ = Rack::Builder.parse_file(config_file('frozen.ru')) + response = Rack::MockRequest.new(app).get('/') + response.body.must_equal 'frozen' + body = response.instance_variable_get(:@body) + body.must_equal(['frozen']) + body[0].frozen?.must_equal true end end diff --git a/test/spec_cascade.rb b/test/spec_cascade.rb index b372a56d2f46c345a63903f0a18ccbaf4be4f3b7..8f1fd131ce915c935a0b3060e162fa86cb1bbafd 100644 --- a/test/spec_cascade.rb +++ b/test/spec_cascade.rb @@ -1,12 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack' -require 'rack/cascade' -require 'rack/files' -require 'rack/lint' -require 'rack/urlmap' -require 'rack/mock' +require_relative 'helper' describe Rack::Cascade do def cascade(*args) @@ -37,10 +31,42 @@ describe Rack::Cascade do Rack::MockRequest.new(cascade).get("/cgi/../bla").must_be :not_found? end + it "include? returns whether app is included" do + cascade = Rack::Cascade.new([app1, app2]) + cascade.include?(app1).must_equal true + cascade.include?(app2).must_equal true + cascade.include?(app3).must_equal false + end + it "return 404 if empty" do Rack::MockRequest.new(cascade([])).get('/').must_be :not_found? end + it "uses new response object if empty" do + app = Rack::Cascade.new([]) + res = app.call('/') + s, h, body = res + s.must_equal 404 + h['Content-Type'].must_equal 'text/plain' + body.must_be_empty + + res[0] = 200 + h['Content-Type'] = 'text/html' + body << "a" + + res = app.call('/') + s, h, body = res + s.must_equal 404 + h['Content-Type'].must_equal 'text/plain' + body.must_be_empty + end + + it "returns final response if all responses are cascaded" do + app = Rack::Cascade.new([]) + app << lambda { |env| [405, {}, []] } + app.call({})[0].must_equal 405 + end + it "append new app" do cascade = Rack::Cascade.new([], [404, 403]) Rack::MockRequest.new(cascade).get('/').must_be :not_found? diff --git a/test/spec_chunked.rb b/test/spec_chunked.rb index 23f640a5bf1915c570d75dc380e7a99916c3b45b..ceb7bdfb2e1df2e03b4a2fb77e25ccb3474347c0 100644 --- a/test/spec_chunked.rb +++ b/test/spec_chunked.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/chunked' -require 'rack/lint' -require 'rack/mock' +require_relative 'helper' describe Rack::Chunked do def chunked(app) @@ -57,6 +54,19 @@ describe Rack::Chunked do response.body.must_equal "0\r\n\r\n" end + it 'closes body' do + obj = Object.new + closed = false + def obj.each; yield 's' end + obj.define_singleton_method(:close) { closed = true } + app = lambda { |env| [200, { "Content-Type" => "text/plain" }, obj] } + response = Rack::MockRequest.new(Rack::Chunked.new(app)).get('/', @env) + response.headers.wont_include 'Content-Length' + response.headers['Transfer-Encoding'].must_equal 'chunked' + response.body.must_equal "1\r\ns\r\n0\r\n\r\n" + closed.must_equal true + end + it 'chunks encoded bodies properly' do body = ["\uFFFEHello", " ", "World"].map {|t| t.encode("UTF-16LE") } app = lambda { |env| [200, { "Content-Type" => "text/plain" }, body] } diff --git a/test/spec_common_logger.rb b/test/spec_common_logger.rb index 330a6480b8ebf8b8ab0937d14998e6ff2a7f5c55..4ddb5f03d374214e78467587e65b100f6f0d93f2 100644 --- a/test/spec_common_logger.rb +++ b/test/spec_common_logger.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/common_logger' -require 'rack/lint' -require 'rack/mock' - +require_relative 'helper' require 'logger' describe Rack::CommonLogger do @@ -23,6 +19,10 @@ describe Rack::CommonLogger do [200, { "Content-Type" => "text/html", "Content-Length" => "0" }, []]} + app_without_lint = lambda { |env| + [200, + { "content-type" => "text/html", "content-length" => length.to_s }, + [obj]]} it "log to rack.errors by default" do res = Rack::MockRequest.new(Rack::CommonLogger.new(app)).get("/") @@ -38,7 +38,7 @@ describe Rack::CommonLogger do log.string.must_match(/"GET \/ " 200 #{length} /) end - it "work with standartd library logger" do + it "work with standard library logger" do logdev = StringIO.new log = Logger.new(logdev) Rack::MockRequest.new(Rack::CommonLogger.new(app, log)).get("/") @@ -87,6 +87,30 @@ describe Rack::CommonLogger do (0..1).must_include duration.to_f end + it "escapes non printable characters except newline" do + logdev = StringIO.new + log = Logger.new(logdev) + Rack::MockRequest.new(Rack::CommonLogger.new(app_without_lint, log)).request("GET\b", "/hello") + + logdev.string.must_match(/GET\\x8 \/hello/) + end + + it "log path with PATH_INFO" do + logdev = StringIO.new + log = Logger.new(logdev) + Rack::MockRequest.new(Rack::CommonLogger.new(app, log)).get("/hello") + + logdev.string.must_match(/"GET \/hello " 200 #{length} /) + end + + it "log path with SCRIPT_NAME" do + logdev = StringIO.new + log = Logger.new(logdev) + Rack::MockRequest.new(Rack::CommonLogger.new(app, log)).get("/path", script_name: "/script") + + logdev.string.must_match(/"GET \/script\/path " 200 #{length} /) + end + def length 123 end diff --git a/test/spec_conditional_get.rb b/test/spec_conditional_get.rb index 8402f04e86654e204510e312b2f6c2d2b1956538..5d517be4dad6276d94bbf8ee5d38f7339d2e1c01 100644 --- a/test/spec_conditional_get.rb +++ b/test/spec_conditional_get.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' +require_relative 'helper' require 'time' -require 'rack/conditional_get' -require 'rack/mock' describe Rack::ConditionalGet do def conditional_get(app) @@ -44,6 +42,17 @@ describe Rack::ConditionalGet do response.body.must_be :empty? end + it "set a 304 status and truncate body when If-None-Match hits but If-Modified-Since is after Last-Modified" do + app = conditional_get(lambda { |env| + [200, { 'Last-Modified' => (Time.now + 3600).httpdate, 'Etag' => '1234', 'Content-Type' => 'text/plain' }, ['TEST']] }) + + response = Rack::MockRequest.new(app). + get("/", 'HTTP_IF_MODIFIED_SINCE' => Time.now.httpdate, 'HTTP_IF_NONE_MATCH' => '1234') + + response.status.must_equal 304 + response.body.must_be :empty? + end + it "not set a 304 status if If-Modified-Since hits but Etag does not" do timestamp = Time.now.httpdate app = conditional_get(lambda { |env| diff --git a/test/spec_config.rb b/test/spec_config.rb index d97107b68c5c95ce78f5e18c32bdfc0716819e72..304ef8bf715a67026821b03f717927b090b5511c 100644 --- a/test/spec_config.rb +++ b/test/spec_config.rb @@ -1,11 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/builder' -require 'rack/config' -require 'rack/content_length' -require 'rack/lint' -require 'rack/mock' +require_relative 'helper' describe Rack::Config do it "accept a block that modifies the environment" do diff --git a/test/spec_content_length.rb b/test/spec_content_length.rb index 2e7a858155fe6121bad6dea6dbd1ec0a9f18ead6..07a4c56e72ce7f5bd81160d9f7851fe494f826e6 100644 --- a/test/spec_content_length.rb +++ b/test/spec_content_length.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/content_length' -require 'rack/lint' -require 'rack/mock' +require_relative 'helper' describe Rack::ContentLength do def content_length(app) @@ -20,13 +17,13 @@ describe Rack::ContentLength do response[1]['Content-Length'].must_equal '13' end - it "not set Content-Length on variable length bodies" do + it "set Content-Length on variable length bodies" do body = lambda { "Hello World!" } def body.each ; yield call ; end app = lambda { |env| [200, { 'Content-Type' => 'text/plain' }, body] } response = content_length(app).call(request) - response[1]['Content-Length'].must_be_nil + response[1]['Content-Length'].must_equal '12' end it "not change Content-Length if it is already set" do diff --git a/test/spec_content_type.rb b/test/spec_content_type.rb index 53f1d1728c3a6a2f2a3b0b2fe3452662e0f9dc05..4cfc32231f84e15f2af996eaa541fcb6c4a8cdca 100644 --- a/test/spec_content_type.rb +++ b/test/spec_content_type.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/content_type' -require 'rack/lint' -require 'rack/mock' +require_relative 'helper' describe Rack::ContentType do def content_type(app, *args) diff --git a/test/spec_deflater.rb b/test/spec_deflater.rb index 378e2cf30a3d8ebcc792f454fb0e6e6c2d8dec9a..ed9cffeca63f362c9e9c51054bdded6a0b7912cd 100644 --- a/test/spec_deflater.rb +++ b/test/spec_deflater.rb @@ -1,11 +1,7 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'stringio' +require_relative 'helper' require 'time' # for Time#httpdate -require 'rack/deflater' -require 'rack/lint' -require 'rack/mock' require 'zlib' describe Rack::Deflater do @@ -76,7 +72,7 @@ describe Rack::Deflater do Time.httpdate(last_mod).to_i.must_equal mtime else mtime.must_be(:<=, Time.now.to_i) - mtime.must_be(:>=, start.to_i) + mtime.must_be(:>=, start.to_i - 1) end tmp = gz.read gz.close diff --git a/test/spec_directory.rb b/test/spec_directory.rb index 6d84a2bffa6e0f2fb0f5892bde89e9b69a985e07..0e4d501fbcecbfa1ef22757e9c8fdc8a83f98396 100644 --- a/test/spec_directory.rb +++ b/test/spec_directory.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/directory' -require 'rack/lint' -require 'rack/mock' +require_relative 'helper' require 'tempfile' require 'fileutils' @@ -43,6 +40,32 @@ describe Rack::Directory do assert_match(res, /<html><head>/) end + it "serve directory indices with bad symlinks" do + begin + File.symlink('foo', 'test/cgi/foo') + res = Rack::MockRequest.new(Rack::Lint.new(app)). + get("/cgi/") + + res.must_be :ok? + assert_match(res, /<html><head>/) + ensure + File.delete('test/cgi/foo') + end + end + + it "return 404 for unreadable directories" do + begin + File.write('test/cgi/unreadable', '') + File.chmod(0, 'test/cgi/unreadable') + res = Rack::MockRequest.new(Rack::Lint.new(app)). + get("/cgi/unreadable") + + res.status.must_equal 404 + ensure + File.delete('test/cgi/unreadable') + end + end + it "pass to app if file found" do res = Rack::MockRequest.new(Rack::Lint.new(app)). get("/cgi/test") @@ -72,30 +95,30 @@ describe Rack::Directory do res.must_be :bad_request? end + it "allow directory traversal inside root directory" do + res = Rack::MockRequest.new(Rack::Lint.new(app)). + get("/cgi/../rackup") + + res.must_be :ok? + + res = Rack::MockRequest.new(Rack::Lint.new(app)). + get("/cgi/%2E%2E/rackup") + + res.must_be :ok? + end + it "not allow directory traversal" do res = Rack::MockRequest.new(Rack::Lint.new(app)). - get("/cgi/../test") + get("/cgi/../../lib") res.must_be :forbidden? res = Rack::MockRequest.new(Rack::Lint.new(app)). - get("/cgi/%2E%2E/test") + get("/cgi/%2E%2E/%2E%2E/lib") res.must_be :forbidden? end - it "not allow dir globs" do - Dir.mktmpdir do |dir| - weirds = "uploads/.?/.?" - full_dir = File.join(dir, weirds) - FileUtils.mkdir_p full_dir - FileUtils.touch File.join(dir, "secret.txt") - app = Rack::Directory.new(File.join(dir, "uploads")) - res = Rack::MockRequest.new(app).get("/.%3F") - refute_match "secret.txt", res.body - end - end - it "404 if it can't find the file" do res = Rack::MockRequest.new(Rack::Lint.new(app)). get("/cgi/blubb") @@ -109,7 +132,8 @@ describe Rack::Directory do res = mr.get("/cgi/test%2bdirectory") res.must_be :ok? - res.body.must_match(%r[/cgi/test\+directory/test\+file]) + res.body.must_match(Regexp.new(Rack::Utils.escape_html( + "/cgi/test\\+directory/test\\+file"))) res = mr.get("/cgi/test%2bdirectory/test%2bfile") res.must_be :ok? @@ -129,7 +153,25 @@ describe Rack::Directory do str = ''.dup body.each { |x| str << x } - assert_match "/foo%20bar/omg%20omg.txt", str + assert_match Rack::Utils.escape_html("/foo%20bar/omg%20omg.txt"), str + end + end + + it "correctly escape script name with '" do + Dir.mktmpdir do |dir| + quote_dir = "foo'bar" + full_dir = File.join(dir, quote_dir) + FileUtils.mkdir full_dir + FileUtils.touch File.join(full_dir, "omg'omg.txt") + app = Rack::Directory.new(dir, FILE_CATCH) + env = Rack::MockRequest.env_for(Rack::Utils.escape("/#{quote_dir}/")) + status, _, body = app.call env + + assert_equal 200, status + + str = ''.dup + body.each { |x| str << x } + assert_match Rack::Utils.escape_html("/foo'bar/omg'omg.txt"), str end end @@ -146,7 +188,8 @@ describe Rack::Directory do res = mr.get("/script-path/cgi/test%2bdirectory") res.must_be :ok? - res.body.must_match(%r[/script-path/cgi/test\+directory/test\+file]) + res.body.must_match(Regexp.new(Rack::Utils.escape_html( + "/script-path/cgi/test\\+directory/test\\+file"))) res = mr.get("/script-path/cgi/test+directory/test+file") res.must_be :ok? diff --git a/test/spec_etag.rb b/test/spec_etag.rb index 750ceaac846ee99505c9a739bde41d2552860431..77c2dfc32002d7fdf49189d715ee609f7dec6481 100644 --- a/test/spec_etag.rb +++ b/test/spec_etag.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/etag' -require 'rack/lint' -require 'rack/mock' +require_relative 'helper' require 'time' describe Rack::ETag do @@ -51,6 +48,12 @@ describe Rack::ETag do response[1]['Cache-Control'].must_equal 'no-cache' end + it "does not set a cache-control if it is already set" do + app = lambda { |env| [201, { 'Content-Type' => 'text/plain', 'cache-control' => 'public' }, ["Hello, World!"]] } + response = etag(app).call(request) + response[1]['cache-control'].must_equal 'public' + end + it "not set Cache-Control if it is already set" do app = lambda { |env| [201, { 'Content-Type' => 'text/plain', 'Cache-Control' => 'public' }, ["Hello, World!"]] } response = etag(app).call(request) @@ -93,10 +96,10 @@ describe Rack::ETag do response[1]['ETag'].must_be_nil end - it "not set ETag if no-cache is given" do + it "set ETag even if no-cache is given" do app = lambda { |env| [200, { 'Content-Type' => 'text/plain', 'Cache-Control' => 'no-cache, must-revalidate' }, ['Hello, World!']] } response = etag(app).call(request) - response[1]['ETag'].must_be_nil + response[1]['ETag'].must_equal "W/\"dffd6021bb2bd5b0af676290809ec3a5\"" end it "close the original body" do diff --git a/test/spec_events.rb b/test/spec_events.rb index 8c079361cff197058f821a85556f7de23a041639..e2077984d0f9c76861e174e42e83bd8d0c45bc23 100644 --- a/test/spec_events.rb +++ b/test/spec_events.rb @@ -1,34 +1,33 @@ # frozen_string_literal: true -require 'helper' -require 'rack/events' +require_relative 'helper' module Rack - class TestEvents < Rack::TestCase + class TestEvents < Minitest::Test class EventMiddleware attr_reader :events - def initialize events + def initialize(events) @events = events end - def on_start req, res + def on_start(req, res) events << [self, __method__] end - def on_commit req, res + def on_commit(req, res) events << [self, __method__] end - def on_send req, res + def on_send(req, res) events << [self, __method__] end - def on_finish req, res + def on_finish(req, res) events << [self, __method__] end - def on_error req, res, e + def on_error(req, res, e) events << [self, __method__] end end diff --git a/test/spec_files.rb b/test/spec_files.rb index 6e75c073a00f509a571d890b0e9d60d885599c89..898b0d9095b19d4c31812f3b087ad15befef901d 100644 --- a/test/spec_files.rb +++ b/test/spec_files.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/files' -require 'rack/lint' -require 'rack/mock' +require_relative 'helper' describe Rack::Files do DOCROOT = File.expand_path(File.dirname(__FILE__)) unless defined? DOCROOT @@ -20,12 +17,20 @@ describe Rack::Files do request = Rack::Request.new( Rack::MockRequest.env_for("/cgi/test") ) - + file_path = File.expand_path("cgi/test", __dir__) status, headers, body = app.serving(request, file_path) assert_equal 200, status end + it 'raises if you attempt to define response_body in subclass' do + c = Class.new(Rack::Files) + + lambda do + c.send(:define_method, :response_body){} + end.must_raise RuntimeError + end + it 'serves files with + in the file name' do Dir.mktmpdir do |dir| File.write File.join(dir, "you+me.txt"), "hello world" @@ -48,6 +53,11 @@ describe Rack::Files do assert_match(res, /ruby/) end + it "does not serve directories" do + res = Rack::MockRequest.new(files(DOCROOT)).get("/cgi/assets") + res.status.must_equal 404 + end + it "set Last-Modified header" do res = Rack::MockRequest.new(files(DOCROOT)).get("/cgi/test") @@ -79,7 +89,7 @@ describe Rack::Files do res.must_be :ok? # res.must_match(/ruby/) # nope - # (/ruby/).must_match res # This is wierd, but an oddity of minitest + # (/ruby/).must_match res # This is weird, but an oddity of minitest # assert_match(/ruby/, res) # nope assert_match(res, /ruby/) end @@ -165,6 +175,15 @@ describe Rack::Files do body.to_path.must_equal path end + it "return bodies that do not respond to #to_path if a byte range is requested" do + env = Rack::MockRequest.env_for("/cgi/test") + env["HTTP_RANGE"] = "bytes=22-33" + status, _, body = Rack::Files.new(DOCROOT).call(env) + + status.must_equal 206 + body.wont_respond_to :to_path + end + it "return correct byte range in body" do env = Rack::MockRequest.env_for("/cgi/test") env["HTTP_RANGE"] = "bytes=22-33" @@ -176,6 +195,32 @@ describe Rack::Files do res.body.must_equal "frozen_strin" end + it "return correct multiple byte ranges in body" do + env = Rack::MockRequest.env_for("/cgi/test") + env["HTTP_RANGE"] = "bytes=22-33, 60-80" + res = Rack::MockResponse.new(*files(DOCROOT).call(env)) + + res.status.must_equal 206 + res["Content-Length"].must_equal "191" + res["Content-Type"].must_equal "multipart/byteranges; boundary=AaB03x" + expected_body = <<-EOF +\r +--AaB03x\r +Content-Type: text/plain\r +Content-Range: bytes 22-33/208\r +\r +frozen_strin\r +--AaB03x\r +Content-Type: text/plain\r +Content-Range: bytes 60-80/208\r +\r +e.join(File.dirname(_\r +--AaB03x--\r + EOF + + res.body.must_equal expected_body + end + it "return error for unsatisfiable byte range" do env = Rack::MockRequest.env_for("/cgi/test") env["HTTP_RANGE"] = "bytes=1234-5678" @@ -263,18 +308,4 @@ describe Rack::Files do res.must_be :not_found? res.body.must_be :empty? end - - class MyFile < Rack::File - def response_body - "hello world" - end - end - - it "behaves gracefully if response_body is present" do - file = Rack::Lint.new MyFile.new(DOCROOT) - res = Rack::MockRequest.new(file).get("/cgi/test") - - res.must_be :ok? - end - end diff --git a/test/spec_handler.rb b/test/spec_handler.rb index 5746dc2251a7bf6e0513442d47d6098ae293c5b9..d6d9cccec38b39848c3e0a87c1994b4c53da1e57 100644 --- a/test/spec_handler.rb +++ b/test/spec_handler.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/handler' +require_relative 'helper' class Rack::Handler::Lobster; end class RockLobster; end diff --git a/test/spec_head.rb b/test/spec_head.rb index f6f41a5d9d506e247db1abeda25ee1568d71dd37..d2dedd28167051033c69c207bacfe6d382458ff7 100644 --- a/test/spec_head.rb +++ b/test/spec_head.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/head' -require 'rack/lint' -require 'rack/mock' +require_relative 'helper' describe Rack::Head do diff --git a/test/spec_lint.rb b/test/spec_lint.rb index 192f260f09ff8a922f820dc58a82cd4725fb3ebb..5df61435d87210ce75c2caa42107a9ca3e349746 100644 --- a/test/spec_lint.rb +++ b/test/spec_lint.rb @@ -1,10 +1,7 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'stringio' +require_relative 'helper' require 'tempfile' -require 'rack/lint' -require 'rack/mock' describe Rack::Lint do def env(*args) @@ -26,6 +23,10 @@ describe Rack::Lint do lambda { Rack::Lint.new(nil).call 5 }.must_raise(Rack::Lint::LintError). message.must_match(/not a Hash/) + lambda { Rack::Lint.new(nil).call({}.freeze) }.must_raise(Rack::Lint::LintError). + message.must_match(/env should not be frozen, but is/) + + lambda { e = env e.delete("REQUEST_METHOD") @@ -72,10 +73,66 @@ describe Rack::Lint do message.must_equal "session [] must respond to store and []=" lambda { - Rack::Lint.new(nil).call(env("rack.logger" => [])) + Rack::Lint.new(nil).call(env("rack.session" => {}.freeze)) + }.must_raise(Rack::Lint::LintError). + message.must_equal "session {} must respond to to_hash and return unfrozen Hash instance" + + obj = {} + obj.singleton_class.send(:undef_method, :to_hash) + lambda { + Rack::Lint.new(nil).call(env("rack.session" => obj)) + }.must_raise(Rack::Lint::LintError). + message.must_equal "session {} must respond to to_hash and return unfrozen Hash instance" + + obj.singleton_class.send(:undef_method, :clear) + lambda { + Rack::Lint.new(nil).call(env("rack.session" => obj)) + }.must_raise(Rack::Lint::LintError). + message.must_equal "session {} must respond to clear" + + obj.singleton_class.send(:undef_method, :delete) + lambda { + Rack::Lint.new(nil).call(env("rack.session" => obj)) + }.must_raise(Rack::Lint::LintError). + message.must_equal "session {} must respond to delete" + + obj.singleton_class.send(:undef_method, :fetch) + lambda { + Rack::Lint.new(nil).call(env("rack.session" => obj)) + }.must_raise(Rack::Lint::LintError). + message.must_equal "session {} must respond to fetch and []" + + obj = Object.new + def obj.inspect; '[]' end + lambda { + Rack::Lint.new(nil).call(env("rack.logger" => obj)) }.must_raise(Rack::Lint::LintError). message.must_equal "logger [] must respond to info" + def obj.info(*) end + lambda { + Rack::Lint.new(nil).call(env("rack.logger" => obj)) + }.must_raise(Rack::Lint::LintError). + message.must_equal "logger [] must respond to debug" + + def obj.debug(*) end + lambda { + Rack::Lint.new(nil).call(env("rack.logger" => obj)) + }.must_raise(Rack::Lint::LintError). + message.must_equal "logger [] must respond to warn" + + def obj.warn(*) end + lambda { + Rack::Lint.new(nil).call(env("rack.logger" => obj)) + }.must_raise(Rack::Lint::LintError). + message.must_equal "logger [] must respond to error" + + def obj.error(*) end + lambda { + Rack::Lint.new(nil).call(env("rack.logger" => obj)) + }.must_raise(Rack::Lint::LintError). + message.must_equal "logger [] must respond to fatal" + lambda { Rack::Lint.new(nil).call(env("rack.multipart.buffer_size" => 0)) }.must_raise(Rack::Lint::LintError). @@ -93,11 +150,24 @@ describe Rack::Lint do }.must_raise(Rack::Lint::LintError). message.must_equal "rack.multipart.tempfile_factory return value must respond to #<<" + lambda { + Rack::Lint.new(lambda { |env| + env['rack.multipart.tempfile_factory'].call("testfile", "text/plain") + [] + }).call(env("rack.multipart.tempfile_factory" => lambda { |filename, content_type| String.new })) + }.must_raise(Rack::Lint::LintError). + message.must_equal "response array has 0 elements instead of 3" + lambda { Rack::Lint.new(nil).call(env("REQUEST_METHOD" => "FUCKUP?")) }.must_raise(Rack::Lint::LintError). message.must_match(/REQUEST_METHOD/) + lambda { + Rack::Lint.new(nil).call(env("REQUEST_METHOD" => "OOPS?\b!")) + }.must_raise(Rack::Lint::LintError). + message.must_match(/OOPS\?\\/) + lambda { Rack::Lint.new(nil).call(env("SCRIPT_NAME" => "howdy")) }.must_raise(Rack::Lint::LintError). @@ -113,6 +183,20 @@ describe Rack::Lint do }.must_raise(Rack::Lint::LintError). message.must_match(/Invalid CONTENT_LENGTH/) + lambda { + Rack::Lint.new(nil).call(env("QUERY_STRING" => nil)) + }.must_raise(Rack::Lint::LintError). + message.must_include('env variable QUERY_STRING has non-string value nil') + + lambda { + Rack::Lint.new(nil).call(env("QUERY_STRING" => "\u1234")) + }.must_raise(Rack::Lint::LintError). + message.must_include('env variable QUERY_STRING has value containing non-ASCII characters and has non-ASCII-8BIT encoding') + + Rack::Lint.new(lambda { |env| + [200, {}, []] + }).call(env("QUERY_STRING" => "\u1234".b)).first.must_equal 200 + lambda { e = env e.delete("PATH_INFO") @@ -158,11 +242,29 @@ describe Rack::Lint do it "notice error errors" do lambda { - Rack::Lint.new(nil).call(env("rack.errors" => "")) + io = StringIO.new + io.binmode + Rack::Lint.new(nil).call(env("rack.errors" => "", "rack.input" => io)) }.must_raise(Rack::Lint::LintError). message.must_match(/does not respond to #puts/) end + it "notice response errors" do + lambda { + Rack::Lint.new(lambda { |env| + "" + }).call(env({})) + }.must_raise(Rack::Lint::LintError). + message.must_include('response is not an Array, but String') + + lambda { + Rack::Lint.new(lambda { |env| + [nil, nil, nil, nil] + }).call(env({})) + }.must_raise(Rack::Lint::LintError). + message.must_include('response array has 4 elements instead of 3') + end + it "notice status errors" do lambda { Rack::Lint.new(lambda { |env| @@ -181,9 +283,12 @@ describe Rack::Lint do it "notice header errors" do lambda { + io = StringIO.new('a') + io.binmode Rack::Lint.new(lambda { |env| + env['rack.input'].each{ |x| } [200, Object.new, []] - }).call(env({})) + }).call(env({ "rack.input" => io })) }.must_raise(Rack::Lint::LintError). message.must_equal "headers object should respond to #each, but doesn't (got Object as headers)" @@ -320,6 +425,7 @@ describe Rack::Lint do lambda { Rack::Lint.new(lambda { |env| + env["rack.input"].gets env["rack.input"].read(1, 2, 3) [201, { "Content-type" => "text/plain", "Content-length" => "0" }, []] }).call(env({})) @@ -503,6 +609,28 @@ describe Rack::Lint do assert_lint nil, ''.dup assert_lint 1, ''.dup end + + it "notice hijack errors" do + lambda { + Rack::Lint.new(lambda { |env| + env['rack.hijack'].call + [201, { "Content-type" => "text/plain", "Content-length" => "0" }, []] + }).call(env({ 'rack.hijack?' => true, 'rack.hijack' => lambda { Object.new } })) + }.must_raise(Rack::Lint::LintError). + message.must_match(/rack.hijack_io must respond to read/) + + Rack::Lint.new(lambda { |env| + env['rack.hijack'].call + [201, { "Content-type" => "text/plain", "Content-length" => "0" }, []] + }).call(env({ 'rack.hijack?' => true, 'rack.hijack' => lambda { StringIO.new }, 'rack.hijack_io' => StringIO.new })). + first.must_equal 201 + + Rack::Lint.new(lambda { |env| + env['rack.hijack?'] = true + [201, { "Content-type" => "text/plain", "Content-length" => "0", 'rack.hijack' => lambda {|io| io }, 'rack.hijack_io' => StringIO.new }, []] + }).call(env({}))[1]['rack.hijack'].call(StringIO.new).read.must_equal '' + end + end describe "Rack::Lint::InputWrapper" do diff --git a/test/spec_lobster.rb b/test/spec_lobster.rb index 9f3b9a897d512b07fab2cd268790eaf76c75e6fd..ac3f11934c258b4e29e7dfebe50b58e6bbe1e8e7 100644 --- a/test/spec_lobster.rb +++ b/test/spec_lobster.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' +require_relative 'helper' require 'rack/lobster' -require 'rack/lint' -require 'rack/mock' module LobsterHelpers def lobster diff --git a/test/spec_lock.rb b/test/spec_lock.rb index cd9e1230d2d8117cad30b49a9757a3b1385339df..895704986b6c75ae2b147d036ca740499520628c 100644 --- a/test/spec_lock.rb +++ b/test/spec_lock.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/lint' -require 'rack/lock' -require 'rack/mock' +require_relative 'helper' class Lock attr_reader :synchronized diff --git a/test/spec_logger.rb b/test/spec_logger.rb index f453b14df369f1ab82cd771af5a581df08b86d4f..8355fc828485a2a118e1618121f6b18f5cef9016 100644 --- a/test/spec_logger.rb +++ b/test/spec_logger.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'stringio' -require 'rack/lint' -require 'rack/logger' -require 'rack/mock' +require_relative 'helper' describe Rack::Logger do app = lambda { |env| diff --git a/test/spec_media_type.rb b/test/spec_media_type.rb index 7d52b4d48e6a1acfbcf3eda942ae3670be2b82cf..a00a767e04a7a09ab2a59a1a12d47396b15ef6ab 100644 --- a/test/spec_media_type.rb +++ b/test/spec_media_type.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/media_type' +require_relative 'helper' describe Rack::MediaType do before { @empty_hash = {} } diff --git a/test/spec_method_override.rb b/test/spec_method_override.rb index 6b01f7c94600d4b858ecac5bed64bf7ab8e57bb0..ddb105bdfce0883c581c2db21db8eb6ab78b287a 100644 --- a/test/spec_method_override.rb +++ b/test/spec_method_override.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'stringio' -require 'rack/method_override' -require 'rack/mock' +require_relative 'helper' describe Rack::MethodOverride do def app @@ -103,6 +100,13 @@ EOF env[Rack::RACK_ERRORS].read.must_match /Bad request content body/ end + it "not modify REQUEST_METHOD for POST requests when the params are unparseable because too deep" do + env = Rack::MockRequest.env_for("/", method: "POST", input: ("[a]" * 36) + "=1") + app.call env + + env["REQUEST_METHOD"].must_equal "POST" + end + it "not modify REQUEST_METHOD for POST requests when the params are unparseable" do env = Rack::MockRequest.env_for("/", method: "POST", input: "(%bad-params%)") app.call env diff --git a/test/spec_mime.rb b/test/spec_mime.rb index 8d1ca2566d120896138cde6c8275fc05657c20e4..65a77f6f0f0294d1b4965656c2ac5427b762a5ea 100644 --- a/test/spec_mime.rb +++ b/test/spec_mime.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/mime' +require_relative 'helper' describe Rack::Mime do diff --git a/test/spec_mock.rb b/test/spec_mock.rb index d7246d3f930bf02b3bb8dee78bb0424201e63ed7..ed679c3e9bee614cde4832f0f417cfdc64f98695 100644 --- a/test/spec_mock.rb +++ b/test/spec_mock.rb @@ -1,10 +1,8 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' +require_relative 'helper' require 'yaml' -require 'rack/lint' -require 'rack/mock' -require 'stringio' +require_relative 'psych_fix' app = Rack::Lint.new(lambda { |env| req = Rack::Request.new(env) @@ -21,8 +19,8 @@ app = Rack::Lint.new(lambda { |env| req.GET["status"] || 200, "Content-Type" => "text/yaml" ) - response.set_cookie("session_test", { value: "session_test", domain: ".test.com", path: "/" }) - response.set_cookie("secure_test", { value: "secure_test", domain: ".test.com", path: "/", secure: true }) + response.set_cookie("session_test", { value: "session_test", domain: "test.com", path: "/" }) + response.set_cookie("secure_test", { value: "secure_test", domain: "test.com", path: "/", secure: true }) response.set_cookie("persistent_test", { value: "persistent_test", max_age: 15552000, path: "/" }) response.finish }) @@ -50,7 +48,7 @@ describe Rack::MockRequest do it "provide sensible defaults" do res = Rack::MockRequest.new(app).request - env = YAML.load(res.body) + env = YAML.unsafe_load(res.body) env["REQUEST_METHOD"].must_equal "GET" env["SERVER_NAME"].must_equal "example.org" env["SERVER_PORT"].must_equal "80" @@ -63,23 +61,23 @@ describe Rack::MockRequest do it "allow GET/POST/PUT/DELETE/HEAD" do res = Rack::MockRequest.new(app).get("", input: "foo") - env = YAML.load(res.body) + env = YAML.unsafe_load(res.body) env["REQUEST_METHOD"].must_equal "GET" res = Rack::MockRequest.new(app).post("", input: "foo") - env = YAML.load(res.body) + env = YAML.unsafe_load(res.body) env["REQUEST_METHOD"].must_equal "POST" res = Rack::MockRequest.new(app).put("", input: "foo") - env = YAML.load(res.body) + env = YAML.unsafe_load(res.body) env["REQUEST_METHOD"].must_equal "PUT" res = Rack::MockRequest.new(app).patch("", input: "foo") - env = YAML.load(res.body) + env = YAML.unsafe_load(res.body) env["REQUEST_METHOD"].must_equal "PATCH" res = Rack::MockRequest.new(app).delete("", input: "foo") - env = YAML.load(res.body) + env = YAML.unsafe_load(res.body) env["REQUEST_METHOD"].must_equal "DELETE" Rack::MockRequest.env_for("/", method: "HEAD")["REQUEST_METHOD"] @@ -105,11 +103,11 @@ describe Rack::MockRequest do it "allow posting" do res = Rack::MockRequest.new(app).get("", input: "foo") - env = YAML.load(res.body) + env = YAML.unsafe_load(res.body) env["mock.postdata"].must_equal "foo" res = Rack::MockRequest.new(app).post("", input: StringIO.new("foo")) - env = YAML.load(res.body) + env = YAML.unsafe_load(res.body) env["mock.postdata"].must_equal "foo" end @@ -118,7 +116,7 @@ describe Rack::MockRequest do get("https://bla.example.org:9292/meh/foo?bar") res.must_be_kind_of Rack::MockResponse - env = YAML.load(res.body) + env = YAML.unsafe_load(res.body) env["REQUEST_METHOD"].must_equal "GET" env["SERVER_NAME"].must_equal "bla.example.org" env["SERVER_PORT"].must_equal "9292" @@ -132,7 +130,7 @@ describe Rack::MockRequest do get("https://example.org/foo") res.must_be_kind_of Rack::MockResponse - env = YAML.load(res.body) + env = YAML.unsafe_load(res.body) env["REQUEST_METHOD"].must_equal "GET" env["SERVER_NAME"].must_equal "example.org" env["SERVER_PORT"].must_equal "443" @@ -147,7 +145,7 @@ describe Rack::MockRequest do get("foo") res.must_be_kind_of Rack::MockResponse - env = YAML.load(res.body) + env = YAML.unsafe_load(res.body) env["REQUEST_METHOD"].must_equal "GET" env["SERVER_NAME"].must_equal "example.org" env["SERVER_PORT"].must_equal "80" @@ -158,13 +156,13 @@ describe Rack::MockRequest do it "properly convert method name to an uppercase string" do res = Rack::MockRequest.new(app).request(:get) - env = YAML.load(res.body) + env = YAML.unsafe_load(res.body) env["REQUEST_METHOD"].must_equal "GET" end it "accept params and build query string for GET requests" do res = Rack::MockRequest.new(app).get("/foo?baz=2", params: { foo: { bar: "1" } }) - env = YAML.load(res.body) + env = YAML.unsafe_load(res.body) env["REQUEST_METHOD"].must_equal "GET" env["QUERY_STRING"].must_include "baz=2" env["QUERY_STRING"].must_include "foo[bar]=1" @@ -174,7 +172,7 @@ describe Rack::MockRequest do it "accept raw input in params for GET requests" do res = Rack::MockRequest.new(app).get("/foo?baz=2", params: "foo[bar]=1") - env = YAML.load(res.body) + env = YAML.unsafe_load(res.body) env["REQUEST_METHOD"].must_equal "GET" env["QUERY_STRING"].must_include "baz=2" env["QUERY_STRING"].must_include "foo[bar]=1" @@ -184,7 +182,7 @@ describe Rack::MockRequest do it "accept params and build url encoded params for POST requests" do res = Rack::MockRequest.new(app).post("/foo", params: { foo: { bar: "1" } }) - env = YAML.load(res.body) + env = YAML.unsafe_load(res.body) env["REQUEST_METHOD"].must_equal "POST" env["QUERY_STRING"].must_equal "" env["PATH_INFO"].must_equal "/foo" @@ -194,7 +192,7 @@ describe Rack::MockRequest do it "accept raw input in params for POST requests" do res = Rack::MockRequest.new(app).post("/foo", params: "foo[bar]=1") - env = YAML.load(res.body) + env = YAML.unsafe_load(res.body) env["REQUEST_METHOD"].must_equal "POST" env["QUERY_STRING"].must_equal "" env["PATH_INFO"].must_equal "/foo" @@ -205,7 +203,7 @@ describe Rack::MockRequest do it "accept params and build multipart encoded params for POST requests" do files = Rack::Multipart::UploadedFile.new(File.join(File.dirname(__FILE__), "multipart", "file1.txt")) res = Rack::MockRequest.new(app).post("/foo", params: { "submit-name" => "Larry", "files" => files }) - env = YAML.load(res.body) + env = YAML.unsafe_load(res.body) env["REQUEST_METHOD"].must_equal "POST" env["QUERY_STRING"].must_equal "" env["PATH_INFO"].must_equal "/foo" @@ -248,6 +246,17 @@ describe Rack::MockRequest do end describe Rack::MockResponse do + it 'has standard constructor' do + headers = { "header" => "value" } + body = ["body"] + + response = Rack::MockResponse[200, headers, body] + + response.status.must_equal 200 + response.headers.must_equal headers + response.body.must_equal body.join + end + it "provide access to the HTTP status" do res = Rack::MockRequest.new(app).get("") res.must_be :successful? @@ -284,7 +293,7 @@ describe Rack::MockResponse do res = Rack::MockRequest.new(app).get("") session_cookie = res.cookie("session_test") session_cookie.value[0].must_equal "session_test" - session_cookie.domain.must_equal ".test.com" + session_cookie.domain.must_equal "test.com" session_cookie.path.must_equal "/" session_cookie.secure.must_equal false session_cookie.expires.must_be_nil @@ -305,7 +314,7 @@ describe Rack::MockResponse do res = Rack::MockRequest.new(app).get("") secure_cookie = res.cookie("secure_test") secure_cookie.value[0].must_equal "secure_test" - secure_cookie.domain.must_equal ".test.com" + secure_cookie.domain.must_equal "test.com" secure_cookie.path.must_equal "/" secure_cookie.secure.must_equal true secure_cookie.expires.must_be_nil @@ -320,6 +329,9 @@ describe Rack::MockResponse do res = Rack::MockRequest.new(app).get("") res.body.must_match(/rack/) assert_match(res, /rack/) + + res.match('rack')[0].must_equal 'rack' + res.match('banana').must_be_nil end it "provide access to the Rack errors" do @@ -341,6 +353,10 @@ describe Rack::MockResponse do lambda { Rack::MockRequest.new(app).get("/?error=foo", fatal: true) }.must_raise Rack::MockRequest::FatalWarning + + lambda { + Rack::MockRequest.new(lambda { |env| env['rack.errors'].write(env['rack.errors'].string) }).get("/", fatal: true) + }.must_raise(Rack::MockRequest::FatalWarning).message.must_equal '' end end diff --git a/test/spec_multipart.rb b/test/spec_multipart.rb index dbf6fba4d2a29fdcee9d263ede719b2be2e60f58..d5bf30d3b9fece6fdd1f5209e53bbd4af634d392 100644 --- a/test/spec_multipart.rb +++ b/test/spec_multipart.rb @@ -1,11 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack' -require 'rack/multipart' -require 'rack/multipart/parser' -require 'rack/utils' -require 'rack/mock' +require_relative 'helper' require 'timeout' describe Rack::Multipart do @@ -31,6 +26,23 @@ describe Rack::Multipart do Rack::Multipart.parse_multipart(env).must_be_nil end + it "parse multipart content when content type present but disposition is not" do + env = Rack::MockRequest.env_for("/", multipart_fixture(:content_type_and_no_disposition)) + params = Rack::Multipart.parse_multipart(env) + params["text/plain; charset=US-ASCII"].must_equal ["contents"] + end + + it "parse multipart content when content type present but disposition is not when using IO" do + read, write = IO.pipe + env = multipart_fixture(:content_type_and_no_disposition) + write.write(env[:input].read) + write.close + env[:input] = read + env = Rack::MockRequest.env_for("/", multipart_fixture(:content_type_and_no_disposition)) + params = Rack::Multipart.parse_multipart(env) + params["text/plain; charset=US-ASCII"].must_equal ["contents"] + end + it "parse multipart content when content type present but filename is not" do env = Rack::MockRequest.env_for("/", multipart_fixture(:content_type_and_no_filename)) params = Rack::Multipart.parse_multipart(env) @@ -80,12 +92,12 @@ describe Rack::Multipart do params['user_sid'].encoding.must_equal Encoding::UTF_8 end - it "raise RangeError if the key space is exhausted" do + it "raise ParamsTooDeepError if the key space is exhausted" do env = Rack::MockRequest.env_for("/", multipart_fixture(:content_type_and_no_filename)) old, Rack::Utils.key_space_limit = Rack::Utils.key_space_limit, 1 begin - lambda { Rack::Multipart.parse_multipart(env) }.must_raise(RangeError) + lambda { Rack::Multipart.parse_multipart(env) }.must_raise(Rack::QueryParser::ParamsTooDeepError) ensure Rack::Utils.key_space_limit = old end @@ -421,19 +433,6 @@ describe Rack::Multipart do params["files"][:tempfile].read.must_equal "contents" end - it "parse filename with unescaped quotes" do - env = Rack::MockRequest.env_for("/", multipart_fixture(:filename_with_unescaped_quotes)) - params = Rack::Multipart.parse_multipart(env) - params["files"][:type].must_equal "application/octet-stream" - params["files"][:filename].must_equal "escape \"quotes" - params["files"][:head].must_equal "Content-Disposition: form-data; " + - "name=\"files\"; " + - "filename=\"escape \"quotes\"\r\n" + - "Content-Type: application/octet-stream\r\n" - params["files"][:name].must_equal "files" - params["files"][:tempfile].read.must_equal "contents" - end - it "parse filename with escaped quotes and modification param" do env = Rack::MockRequest.env_for("/", multipart_fixture(:filename_with_escaped_quotes_and_modification_param)) params = Rack::Multipart.parse_multipart(env) @@ -442,7 +441,7 @@ describe Rack::Multipart do params["files"][:head].must_equal "Content-Type: image/jpeg\r\n" + "Content-Disposition: attachment; " + "name=\"files\"; " + - "filename=\"\"human\" genome.jpeg\"; " + + "filename=\"\\\"human\\\" genome.jpeg\"; " + "modification-date=\"Wed, 12 Feb 1997 16:29:51 -0500\";\r\n" + "Content-Description: a complete map of the human genome\r\n" params["files"][:name].must_equal "files" @@ -520,7 +519,7 @@ Content-Type: image/jpeg\r params["files"][:tempfile].read.must_equal "contents" end - it "builds nested multipart body" do + it "builds nested multipart body using array" do files = Rack::Multipart::UploadedFile.new(multipart_file("file1.txt")) data = Rack::Multipart.build_multipart("people" => [{ "submit-name" => "Larry", "files" => files }]) @@ -536,6 +535,38 @@ Content-Type: image/jpeg\r params["people"][0]["files"][:tempfile].read.must_equal "contents" end + it "builds nested multipart body using hash" do + files = Rack::Multipart::UploadedFile.new(multipart_file("file1.txt")) + data = Rack::Multipart.build_multipart("people" => { "foo" => { "submit-name" => "Larry", "files" => files } }) + + options = { + "CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x", + "CONTENT_LENGTH" => data.length.to_s, + :input => StringIO.new(data) + } + env = Rack::MockRequest.env_for("/", options) + params = Rack::Multipart.parse_multipart(env) + params["people"]["foo"]["submit-name"].must_equal "Larry" + params["people"]["foo"]["files"][:filename].must_equal "file1.txt" + params["people"]["foo"]["files"][:tempfile].read.must_equal "contents" + end + + it "builds multipart body from StringIO" do + files = Rack::Multipart::UploadedFile.new(io: StringIO.new('foo'), filename: 'bar.txt') + data = Rack::Multipart.build_multipart("submit-name" => "Larry", "files" => files) + + options = { + "CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x", + "CONTENT_LENGTH" => data.length.to_s, + :input => StringIO.new(data) + } + env = Rack::MockRequest.env_for("/", options) + params = Rack::Multipart.parse_multipart(env) + params["submit-name"].must_equal "Larry" + params["files"][:filename].must_equal "bar.txt" + params["files"][:tempfile].read.must_equal "foo" + end + it "can parse fields that end at the end of the buffer" do input = File.read(multipart_file("bad_robots")) @@ -601,6 +632,18 @@ Content-Type: image/jpeg\r end end + it "reach a multipart total limit" do + begin + previous_limit = Rack::Utils.multipart_total_part_limit + Rack::Utils.multipart_total_part_limit = 5 + + env = Rack::MockRequest.env_for '/', multipart_fixture(:three_files_three_fields) + lambda { Rack::Multipart.parse_multipart(env) }.must_raise Rack::Multipart::MultipartTotalPartLimitError + ensure + Rack::Utils.multipart_total_part_limit = previous_limit + end + end + it "return nil if no UploadedFiles were used" do data = Rack::Multipart.build_multipart("people" => [{ "submit-name" => "Larry", "files" => "contents" }]) data.must_be_nil diff --git a/test/spec_null_logger.rb b/test/spec_null_logger.rb index 1037c9fa369b716d951f32581d34182e8504fc2e..435d051eadab6efd6b07649b632675178d69e18c 100644 --- a/test/spec_null_logger.rb +++ b/test/spec_null_logger.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/lint' -require 'rack/mock' -require 'rack/null_logger' +require_relative 'helper' describe Rack::NullLogger do it "act as a noop logger" do diff --git a/test/spec_recursive.rb b/test/spec_recursive.rb index e77d966d5d611c192af0c35e6164964ae536d4de..62e3a4f16bb547d7e84a754b610fa7f35cc21bd5 100644 --- a/test/spec_recursive.rb +++ b/test/spec_recursive.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/lint' -require 'rack/recursive' -require 'rack/mock' +require_relative 'helper' describe Rack::Recursive do before do diff --git a/test/spec_request.rb b/test/spec_request.rb index 583a367e35fa2013b3b56e7e2401bbbec574e48c..51cfcdc88c329c8b7d89d3655ad6dd377f91ce16 100644 --- a/test/spec_request.rb +++ b/test/spec_request.rb @@ -1,11 +1,8 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'stringio' +require_relative 'helper' require 'cgi' -require 'rack/request' -require 'rack/mock' -require 'rack/multipart' +require 'forwardable' require 'securerandom' class RackRequestTest < Minitest::Spec @@ -117,24 +114,37 @@ class RackRequestTest < Minitest::Spec req = make_request \ Rack::MockRequest.env_for("/", "HTTP_HOST" => "www2.example.org") req.host.must_equal "www2.example.org" + req.hostname.must_equal "www2.example.org" + + req = make_request \ + Rack::MockRequest.env_for("/", "HTTP_HOST" => "123foo.example.com") + req.host.must_equal "123foo.example.com" + req.hostname.must_equal "123foo.example.com" req = make_request \ Rack::MockRequest.env_for("/", "SERVER_NAME" => "example.org", "SERVER_PORT" => "9292") req.host.must_equal "example.org" + req.hostname.must_equal "example.org" req = make_request \ Rack::MockRequest.env_for("/", "HTTP_HOST" => "localhost:81", "HTTP_X_FORWARDED_HOST" => "example.org:9292") req.host.must_equal "example.org" + req.hostname.must_equal "example.org" - env = Rack::MockRequest.env_for("/", "SERVER_ADDR" => "192.168.1.1", "SERVER_PORT" => "9292") - env.delete("SERVER_NAME") - req = make_request(env) - req.host.must_equal "192.168.1.1" + req = make_request \ + Rack::MockRequest.env_for("/", "HTTP_HOST" => "localhost:81", "HTTP_X_FORWARDED_HOST" => "[2001:db8:cafe::17]:47011") + req.host.must_equal "[2001:db8:cafe::17]" + req.hostname.must_equal "2001:db8:cafe::17" + + req = make_request \ + Rack::MockRequest.env_for("/", "HTTP_HOST" => "localhost:81", "HTTP_X_FORWARDED_HOST" => "2001:db8:cafe::17") + req.host.must_equal "[2001:db8:cafe::17]" + req.hostname.must_equal "2001:db8:cafe::17" env = Rack::MockRequest.env_for("/") env.delete("SERVER_NAME") req = make_request(env) - req.host.must_equal "" + req.host.must_be_nil end it "figure out the correct port" do @@ -154,6 +164,14 @@ class RackRequestTest < Minitest::Spec Rack::MockRequest.env_for("/", "HTTP_HOST" => "localhost:81", "HTTP_X_FORWARDED_HOST" => "example.org:9292") req.port.must_equal 9292 + req = make_request \ + Rack::MockRequest.env_for("/", "HTTP_HOST" => "localhost:81", "HTTP_X_FORWARDED_HOST" => "[2001:db8:cafe::17]:47011") + req.port.must_equal 47011 + + req = make_request \ + Rack::MockRequest.env_for("/", "HTTP_HOST" => "localhost:81", "HTTP_X_FORWARDED_HOST" => "2001:db8:cafe::17") + req.port.must_equal 80 + req = make_request \ Rack::MockRequest.env_for("/", "HTTP_HOST" => "localhost:81", "HTTP_X_FORWARDED_HOST" => "example.org") req.port.must_equal 80 @@ -162,7 +180,7 @@ class RackRequestTest < Minitest::Spec Rack::MockRequest.env_for("/", "HTTP_HOST" => "localhost:81", "HTTP_X_FORWARDED_HOST" => "example.org", "HTTP_X_FORWARDED_SSL" => "on") req.port.must_equal 443 - req = make_request \ + req = make_request \ Rack::MockRequest.env_for("/", "HTTP_HOST" => "localhost:81", "HTTP_X_FORWARDED_HOST" => "example.org", "HTTP_X_FORWARDED_PROTO" => "https") req.port.must_equal 443 @@ -200,10 +218,22 @@ class RackRequestTest < Minitest::Spec Rack::MockRequest.env_for("/", "SERVER_NAME" => "example.org", "SERVER_PORT" => "9292") req.host_with_port.must_equal "example.org:9292" + req = make_request \ + Rack::MockRequest.env_for("/", "SERVER_NAME" => "example.org") + req.host_with_port.must_equal "example.org" + req = make_request \ Rack::MockRequest.env_for("/", "HTTP_HOST" => "localhost:81", "HTTP_X_FORWARDED_HOST" => "example.org:9292") req.host_with_port.must_equal "example.org:9292" + req = make_request \ + Rack::MockRequest.env_for("/", "HTTP_HOST" => "localhost:81", "HTTP_X_FORWARDED_HOST" => "[2001:db8:cafe::17]:47011") + req.host_with_port.must_equal "[2001:db8:cafe::17]:47011" + + req = make_request \ + Rack::MockRequest.env_for("/", "HTTP_HOST" => "localhost:81", "HTTP_X_FORWARDED_HOST" => "2001:db8:cafe::17") + req.host_with_port.must_equal "[2001:db8:cafe::17]" + req = make_request \ Rack::MockRequest.env_for("/", "HTTP_HOST" => "localhost:81", "HTTP_X_FORWARDED_HOST" => "example.org", "SERVER_PORT" => "9393") req.host_with_port.must_equal "example.org" @@ -262,7 +292,7 @@ class RackRequestTest < Minitest::Spec old, Rack::Utils.key_space_limit = Rack::Utils.key_space_limit, 1 begin req = make_request(env) - lambda { req.GET }.must_raise RangeError + lambda { req.GET }.must_raise Rack::QueryParser::ParamsTooDeepError ensure Rack::Utils.key_space_limit = old end @@ -276,12 +306,37 @@ class RackRequestTest < Minitest::Spec begin exp = { "foo" => { "bar" => { "baz" => { "qux" => "1" } } } } make_request(nested_query).GET.must_equal exp - lambda { make_request(plain_query).GET }.must_raise RangeError + lambda { make_request(plain_query).GET }.must_raise Rack::QueryParser::ParamsTooDeepError ensure Rack::Utils.key_space_limit = old end end + it "limit the allowed parameter depth when parsing parameters" do + env = Rack::MockRequest.env_for("/?a#{'[a]' * 110}=b") + req = make_request(env) + lambda { req.GET }.must_raise Rack::QueryParser::ParamsTooDeepError + + env = Rack::MockRequest.env_for("/?a#{'[a]' * 90}=b") + req = make_request(env) + params = req.GET + 90.times { params = params['a'] } + params['a'].must_equal 'b' + + old, Rack::Utils.param_depth_limit = Rack::Utils.param_depth_limit, 3 + begin + env = Rack::MockRequest.env_for("/?a[a][a]=b") + req = make_request(env) + req.GET['a']['a']['a'].must_equal 'b' + + env = Rack::MockRequest.env_for("/?a[a][a][a]=b") + req = make_request(env) + lambda { make_request(env).GET }.must_raise Rack::QueryParser::ParamsTooDeepError + ensure + Rack::Utils.param_depth_limit = old + end + end + it "not unify GET and POST when calling params" do mr = Rack::MockRequest.env_for("/?foo=quux", "REQUEST_METHOD" => 'POST', @@ -361,7 +416,7 @@ class RackRequestTest < Minitest::Spec old, Rack::Utils.key_space_limit = Rack::Utils.key_space_limit, 1 begin req = make_request(env) - lambda { req.POST }.must_raise RangeError + lambda { req.POST }.must_raise Rack::QueryParser::ParamsTooDeepError ensure Rack::Utils.key_space_limit = old end @@ -388,6 +443,7 @@ class RackRequestTest < Minitest::Spec req.content_type.must_equal 'text/plain;charset=utf-8' req.media_type.must_equal 'text/plain' req.media_type_params['charset'].must_equal 'utf-8' + req.content_charset.must_equal 'utf-8' req.POST.must_be :empty? req.params.must_equal "foo" => "quux" req.body.read.must_equal "foo=bar&quux=bla" @@ -439,6 +495,20 @@ class RackRequestTest < Minitest::Spec Rack::MockRequest.env_for("?foo=quux") req['foo'].must_equal 'quux' req[:foo].must_equal 'quux' + + next if self.class == TestProxyRequest + verbose = $VERBOSE + warn_arg = nil + req.define_singleton_method(:warn) do |arg| + warn_arg = arg + end + begin + $VERBOSE = true + req['foo'].must_equal 'quux' + warn_arg.must_equal "Request#[] is deprecated and will be removed in a future version of Rack. Please use request.params[] instead" + ensure + $VERBOSE = verbose + end end it "set value to key on params with #[]=" do @@ -461,6 +531,20 @@ class RackRequestTest < Minitest::Spec req.params.must_equal 'foo' => 'jaz' req['foo'].must_equal 'jaz' req[:foo].must_equal 'jaz' + + verbose = $VERBOSE + warn_arg = nil + req.define_singleton_method(:warn) do |arg| + warn_arg = arg + end + begin + $VERBOSE = true + req['foo'] = 'quux' + warn_arg.must_equal "Request#[]= is deprecated and will be removed in a future version of Rack. Please use request.params[]= instead" + req.params['foo'].must_equal 'quux' + ensure + $VERBOSE = verbose + end end it "return values for the keys in the order given from values_at" do @@ -549,6 +633,10 @@ class RackRequestTest < Minitest::Spec request.scheme.must_equal "https" request.must_be :ssl? + request = make_request(Rack::MockRequest.env_for("/", 'rack.url_scheme' => 'wss')) + request.scheme.must_equal "wss" + request.must_be :ssl? + request = make_request(Rack::MockRequest.env_for("/", 'HTTP_HOST' => 'www.example.org:8080')) request.scheme.must_equal "http" request.wont_be :ssl? @@ -583,7 +671,6 @@ class RackRequestTest < Minitest::Spec req = make_request \ Rack::MockRequest.env_for("", "HTTP_COOKIE" => "foo=bar;quux=h&m") req.cookies.must_equal "foo" => "bar", "quux" => "h&m" - req.cookies.must_equal "foo" => "bar", "quux" => "h&m" req.delete_header("HTTP_COOKIE") req.cookies.must_equal({}) end @@ -913,7 +1000,7 @@ EOF f[:tempfile].size.must_equal 76 end - it "MultipartPartLimitError when request has too many multipart parts if limit set" do + it "MultipartPartLimitError when request has too many multipart file parts if limit set" do begin data = 10000.times.map { "--AaB03x\r\nContent-Type: text/plain\r\nContent-Disposition: attachment; name=#{SecureRandom.hex(10)}; filename=#{SecureRandom.hex(10)}\r\n\r\ncontents\r\n" }.join("\r\n") data += "--AaB03x--\r" @@ -929,6 +1016,22 @@ EOF end end + it "MultipartPartLimitError when request has too many multipart total parts if limit set" do + begin + data = 10000.times.map { "--AaB03x\r\ncontent-type: text/plain\r\ncontent-disposition: attachment; name=#{SecureRandom.hex(10)}\r\n\r\ncontents\r\n" }.join("\r\n") + data += "--AaB03x--\r" + + options = { + "CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x", + "CONTENT_LENGTH" => data.length.to_s, + :input => StringIO.new(data) + } + + request = make_request Rack::MockRequest.env_for("/", options) + lambda { request.POST }.must_raise Rack::Multipart::MultipartTotalPartLimitError + end + end + it 'closes tempfiles it created in the case of too many created' do begin data = 10000.times.map { "--AaB03x\r\nContent-Type: text/plain\r\nContent-Disposition: attachment; name=#{SecureRandom.hex(10)}; filename=#{SecureRandom.hex(10)}\r\n\r\ncontents\r\n" }.join("\r\n") @@ -1188,6 +1291,12 @@ EOF res = mock.get '/', 'REMOTE_ADDR' => '1.2.3.4,3.4.5.6' res.body.must_equal '1.2.3.4' + + res = mock.get '/', 'REMOTE_ADDR' => '127.0.0.1' + res.body.must_equal '127.0.0.1' + + res = mock.get '/', 'REMOTE_ADDR' => '127.0.0.1,127.0.0.1' + res.body.must_equal '127.0.0.1' end it 'deals with proxies' do diff --git a/test/spec_response.rb b/test/spec_response.rb index c0736c73d7240a094cb1ce40e9e5b993d735d441..1dfafcdb5ff68e01f47569a5eedbc23137a43b33 100644 --- a/test/spec_response.rb +++ b/test/spec_response.rb @@ -1,11 +1,19 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack' -require 'rack/response' -require 'stringio' +require_relative 'helper' describe Rack::Response do + it 'has standard constructor' do + headers = { "header" => "value" } + body = ["body"] + + response = Rack::Response[200, headers, body] + + response.status.must_equal 200 + response.headers.must_equal headers + response.body.must_equal body + end + it 'has cache-control methods' do response = Rack::Response.new cc = 'foo' @@ -22,6 +30,14 @@ describe Rack::Response do assert_equal etag, response.to_a[1]['ETag'] end + it 'has a content-type method' do + response = Rack::Response.new + content_type = 'foo' + response.content_type = content_type + assert_equal content_type, response.content_type + assert_equal content_type, response.to_a[1]['Content-Type'] + end + it "have sensible default values" do response = Rack::Response.new status, header, body = response.finish @@ -40,12 +56,11 @@ describe Rack::Response do } end - it "can be written to" do - response = Rack::Response.new + it "can be written to inside finish block, but does not update Content-Length" do + response = Rack::Response.new('foo') + response.write "bar" - _, _, body = response.finish do - response.write "foo" - response.write "bar" + _, h, body = response.finish do response.write "baz" end @@ -53,6 +68,7 @@ describe Rack::Response do body.each { |part| parts << part } parts.must_equal ["foo", "bar", "baz"] + h['Content-Length'].must_equal '6' end it "can set and read headers" do @@ -63,13 +79,13 @@ describe Rack::Response do end it "doesn't mutate given headers" do - [{}, Rack::Utils::HeaderHash.new].each do |header| - response = Rack::Response.new([], 200, header) - response.header["Content-Type"] = "text/plain" - response.header["Content-Type"].must_equal "text/plain" + headers = {} - header.wont_include("Content-Type") - end + response = Rack::Response.new([], 200, headers) + response.headers["Content-Type"] = "text/plain" + response.headers["Content-Type"].must_equal "text/plain" + + headers.wont_include("Content-Type") end it "can override the initial Content-Type with a different case" do @@ -232,6 +248,18 @@ describe Rack::Response do "foo=; domain=sample.example.com; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"].join("\n") end + it "only deletes cookies for the domain specified" do + response = Rack::Response.new + response.set_cookie "foo", { value: "bar", domain: "example.com.example.com" } + response.set_cookie "foo", { value: "bar", domain: "example.com" } + response["Set-Cookie"].must_equal ["foo=bar; domain=example.com.example.com", "foo=bar; domain=example.com"].join("\n") + response.delete_cookie "foo", domain: "example.com" + response["Set-Cookie"].must_equal ["foo=bar; domain=example.com.example.com", "foo=; domain=example.com; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"].join("\n") + response.delete_cookie "foo", domain: "example.com.example.com" + response["Set-Cookie"].must_equal ["foo=; domain=example.com; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT", + "foo=; domain=example.com.example.com; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"].join("\n") + end + it "can delete cookies with the same name with different paths" do response = Rack::Response.new response.set_cookie "foo", { value: "bar", path: "/" } @@ -244,6 +272,71 @@ describe Rack::Response do "foo=; path=/path; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"].join("\n") end + it "only delete cookies with the path specified" do + response = Rack::Response.new + response.set_cookie "foo", value: "bar", path: "/" + response.set_cookie "foo", value: "bar", path: "/a" + response.set_cookie "foo", value: "bar", path: "/a/b" + response["Set-Cookie"].must_equal ["foo=bar; path=/", + "foo=bar; path=/a", + "foo=bar; path=/a/b"].join("\n") + + response.delete_cookie "foo", path: "/a" + response["Set-Cookie"].must_equal ["foo=bar; path=/", + "foo=bar; path=/a/b", + "foo=; path=/a; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"].join("\n") + end + + it "only delete cookies with the domain and path specified" do + response = Rack::Response.new + response.set_cookie "foo", value: "bar", path: "/" + response.set_cookie "foo", value: "bar", path: "/a" + response.set_cookie "foo", value: "bar", path: "/a/b" + response.set_cookie "foo", value: "bar", path: "/", domain: "example.com.example.com" + response.set_cookie "foo", value: "bar", path: "/a", domain: "example.com.example.com" + response.set_cookie "foo", value: "bar", path: "/a/b", domain: "example.com.example.com" + response.set_cookie "foo", value: "bar", path: "/", domain: "example.com" + response.set_cookie "foo", value: "bar", path: "/a", domain: "example.com" + response.set_cookie "foo", value: "bar", path: "/a/b", domain: "example.com" + response["Set-Cookie"].must_equal [ + "foo=bar; path=/", + "foo=bar; path=/a", + "foo=bar; path=/a/b", + "foo=bar; domain=example.com.example.com; path=/", + "foo=bar; domain=example.com.example.com; path=/a", + "foo=bar; domain=example.com.example.com; path=/a/b", + "foo=bar; domain=example.com; path=/", + "foo=bar; domain=example.com; path=/a", + "foo=bar; domain=example.com; path=/a/b", + ].join("\n") + + response.delete_cookie "foo", path: "/a", domain: "example.com" + response["Set-Cookie"].must_equal [ + "foo=bar; path=/", + "foo=bar; path=/a", + "foo=bar; path=/a/b", + "foo=bar; domain=example.com.example.com; path=/", + "foo=bar; domain=example.com.example.com; path=/a", + "foo=bar; domain=example.com.example.com; path=/a/b", + "foo=bar; domain=example.com; path=/", + "foo=bar; domain=example.com; path=/a/b", + "foo=; domain=example.com; path=/a; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT", + ].join("\n") + + response.delete_cookie "foo", path: "/a/b", domain: "example.com" + response["Set-Cookie"].must_equal [ + "foo=bar; path=/", + "foo=bar; path=/a", + "foo=bar; path=/a/b", + "foo=bar; domain=example.com.example.com; path=/", + "foo=bar; domain=example.com.example.com; path=/a", + "foo=bar; domain=example.com.example.com; path=/a/b", + "foo=bar; domain=example.com; path=/", + "foo=; domain=example.com; path=/a; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT", + "foo=; domain=example.com; path=/a/b; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT", + ].join("\n") + end + it "can do redirects" do response = Rack::Response.new response.redirect "/foo" @@ -298,6 +391,33 @@ describe Rack::Response do status.must_equal 404 end + it "correctly updates Content-Type when writing when not initialized with body" do + r = Rack::Response.new + r.write('foo') + r.write('bar') + r.write('baz') + _, header, body = r.finish + str = "".dup; body.each { |part| str << part } + str.must_equal "foobarbaz" + header['Content-Length'].must_equal '9' + end + + it "correctly updates Content-Type when writing when initialized with body" do + obj = Object.new + def obj.each + yield 'foo' + yield 'bar' + end + ["foobar", ["foo", "bar"], obj].each do + r = Rack::Response.new(["foo", "bar"]) + r.write('baz') + _, header, body = r.finish + str = "".dup; body.each { |part| str << part } + str.must_equal "foobarbaz" + header['Content-Length'].must_equal '9' + end + end + it "doesn't return invalid responses" do r = Rack::Response.new(["foo", "bar"], 204) _, header, body = r.finish @@ -400,12 +520,14 @@ describe Rack::Response do it "provide access to the HTTP headers" do res = Rack::Response.new - res["Content-Type"] = "text/yaml" + res["Content-Type"] = "text/yaml; charset=UTF-8" res.must_include "Content-Type" - res.headers["Content-Type"].must_equal "text/yaml" - res["Content-Type"].must_equal "text/yaml" - res.content_type.must_equal "text/yaml" + res.headers["Content-Type"].must_equal "text/yaml; charset=UTF-8" + res["Content-Type"].must_equal "text/yaml; charset=UTF-8" + res.content_type.must_equal "text/yaml; charset=UTF-8" + res.media_type.must_equal "text/yaml" + res.media_type_params.must_equal "charset" => "UTF-8" res.content_length.must_be_nil res.location.must_be_nil end @@ -495,6 +617,31 @@ describe Rack::Response do res.finish.flatten.must_be_kind_of(Array) end + + it "should specify not to cache content" do + response = Rack::Response.new + + response.cache!(1000) + response.do_not_cache! + + expect(response['Cache-Control']).must_equal "no-cache, must-revalidate" + + expires_header = Time.parse(response['Expires']) + expect(expires_header).must_be :<=, Time.now + end + + it "should specify to cache content" do + response = Rack::Response.new + + duration = 120 + expires = Time.now + 100 # At least this far into the future + response.cache!(duration) + + expect(response['Cache-Control']).must_equal "public, max-age=120" + + expires_header = Time.parse(response['Expires']) + expect(expires_header).must_be :>=, expires + end end describe Rack::Response, 'headers' do diff --git a/test/spec_rewindable_input.rb b/test/spec_rewindable_input.rb index 6bb5f5cf884600795efc945c551f65b6ae2a6616..4efe7dc29c1658096c1707ebbbccf435c9948d51 100644 --- a/test/spec_rewindable_input.rb +++ b/test/spec_rewindable_input.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'stringio' -require 'rack/rewindable_input' +require_relative 'helper' module RewindableTest extend Minitest::Spec::DSL @@ -79,6 +77,27 @@ module RewindableTest tempfile.must_be :closed? end + it "handle partial writes to tempfile" do + def @rio.filesystem_has_posix_semantics? + def @rewindable_io.write(buffer) + super(buffer[0..1]) + end + super + end + @rio.read(1) + tempfile = @rio.instance_variable_get(:@rewindable_io) + @rio.close + tempfile.must_be :closed? + end + + it "close the underlying tempfile upon calling #close when not using posix semantics" do + def @rio.filesystem_has_posix_semantics?; false end + @rio.read(1) + tempfile = @rio.instance_variable_get(:@rewindable_io) + @rio.close + tempfile.must_be :closed? + end + it "be possible to call #close when no data has been buffered yet" do @rio.close.must_be_nil end diff --git a/test/spec_runtime.rb b/test/spec_runtime.rb index 10e561dec1ef6c049bc72c574eb39f2f67b2c457..e4fc3f95a8c4f7e72f51be902395b61403c34ad9 100644 --- a/test/spec_runtime.rb +++ b/test/spec_runtime.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/lint' -require 'rack/mock' -require 'rack/runtime' +require_relative 'helper' describe Rack::Runtime do def runtime_app(app, *args) @@ -14,6 +11,12 @@ describe Rack::Runtime do Rack::MockRequest.env_for end + it "works even if headers is an array" do + app = lambda { |env| [200, [['Content-Type', 'text/plain']], "Hello, World!"] } + response = runtime_app(app).call(request) + response[1]['X-Runtime'].must_match(/[\d\.]+/) + end + it "sets X-Runtime is none is set" do app = lambda { |env| [200, { 'Content-Type' => 'text/plain' }, "Hello, World!"] } response = runtime_app(app).call(request) diff --git a/test/spec_sendfile.rb b/test/spec_sendfile.rb index cbed8db3c1c812c8fcfcc4edff1009dd705b93a7..09e810e9b6cc6ca6a324fde7c71960248abf8940 100644 --- a/test/spec_sendfile.rb +++ b/test/spec_sendfile.rb @@ -1,10 +1,7 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' +require_relative 'helper' require 'fileutils' -require 'rack/lint' -require 'rack/sendfile' -require 'rack/mock' require 'tmpdir' describe Rack::Sendfile do @@ -43,6 +40,18 @@ describe Rack::Sendfile do end end + it "does nothing and logs to rack.errors when incorrect X-Sendfile-Type header present" do + io = StringIO.new + request 'HTTP_X_SENDFILE_TYPE' => 'X-Banana', 'rack.errors' => io do |response| + response.must_be :ok? + response.body.must_equal 'Hello World' + response.headers.wont_include 'X-Sendfile' + + io.rewind + io.read.must_equal "Unknown x-sendfile variation: 'X-Banana'.\n" + end + end + it "sets X-Sendfile response header and discards body" do request 'HTTP_X_SENDFILE_TYPE' => 'X-Sendfile' do |response| response.must_be :ok? @@ -142,6 +151,7 @@ describe Rack::Sendfile do begin dir1 = Dir.mktmpdir dir2 = Dir.mktmpdir + dir3 = Dir.mktmpdir first_body = open_file(File.join(dir1, 'rack_sendfile')) first_body.puts 'hello world' @@ -149,6 +159,9 @@ describe Rack::Sendfile do second_body = open_file(File.join(dir2, 'rack_sendfile')) second_body.puts 'goodbye world' + third_body = open_file(File.join(dir3, 'rack_sendfile')) + third_body.puts 'hello again world' + headers = { 'HTTP_X_SENDFILE_TYPE' => 'X-Accel-Redirect', 'HTTP_X_ACCEL_MAPPING' => "#{dir1}/=/foo/bar/, #{dir2}/=/wibble/" @@ -167,6 +180,13 @@ describe Rack::Sendfile do response.headers['Content-Length'].must_equal '0' response.headers['X-Accel-Redirect'].must_equal '/wibble/rack_sendfile' end + + request(headers, third_body) do |response| + response.must_be :ok? + response.body.must_be :empty? + response.headers['Content-Length'].must_equal '0' + response.headers['X-Accel-Redirect'].must_equal "#{dir3}/rack_sendfile" + end ensure FileUtils.remove_entry_secure dir1 FileUtils.remove_entry_secure dir2 diff --git a/test/spec_server.rb b/test/spec_server.rb index 8aecc554cea8cd1652844ee259799c05142e1860..34912b4d8e2f8f473b6a56851b9e38e5307ff753 100644 --- a/test/spec_server.rb +++ b/test/spec_server.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack' -require 'rack/server' +require_relative 'helper' require 'tempfile' require 'socket' +require 'webrick' require 'open-uri' +require 'net/http' +require 'net/https' module Minitest::Spec::DSL alias :should :it @@ -97,11 +98,17 @@ describe Rack::Server do end it "get options from ARGV" do - SPEC_ARGV[0..-1] = ['--debug', '-sthin', '--env', 'production'] + SPEC_ARGV[0..-1] = ['--debug', '-sthin', '--env', 'production', '-w', '-q', '-o', '127.0.0.1', '-O', 'NAME=VALUE', '-ONAME2', '-D'] server = Rack::Server.new server.options[:debug].must_equal true server.options[:server].must_equal 'thin' server.options[:environment].must_equal 'production' + server.options[:warn].must_equal true + server.options[:quiet].must_equal true + server.options[:Host].must_equal '127.0.0.1' + server.options[:NAME].must_equal 'VALUE' + server.options[:NAME2].must_equal true + server.options[:daemonize].must_equal true end it "only override non-passed options from parsed .ru file" do @@ -116,6 +123,226 @@ describe Rack::Server do server.options[:Port].must_equal '2929' end + def test_options_server(*args) + SPEC_ARGV[0..-1] = args + output = String.new + server = Class.new(Rack::Server) do + define_method(:opt_parser) do + Class.new(Rack::Server::Options) do + define_method(:puts) do |*args| + output << args.join("\n") << "\n" + end + alias warn puts + alias abort puts + define_method(:exit) do + output << "exited" + end + end.new + end + end.new + output + end + + it "support -h option to get help" do + test_options_server('-scgi', '-h').must_match(/\AUsage: rackup.*Ruby options:.*Rack options.*Profiling options.*Common options.*exited\z/m) + end + + it "support -h option to get handler-specific help" do + cgi = Rack::Handler.get('cgi') + begin + def cgi.valid_options; { "FOO=BAR" => "BAZ" } end + test_options_server('-scgi', '-h').must_match(/\AUsage: rackup.*Ruby options:.*Rack options.*Profiling options.*Common options.*Server-specific options for Rack::Handler::CGI.*-O +FOO=BAR +BAZ.*exited\z/m) + ensure + cgi.singleton_class.send(:remove_method, :valid_options) + end + end + + it "support -h option to display warning for invalid handler" do + test_options_server('-sbanana', '-h').must_match(/\AUsage: rackup.*Ruby options:.*Rack options.*Profiling options.*Common options.*Warning: Could not find handler specified \(banana\) to determine handler-specific options.*exited\z/m) + end + + it "support -v option to get version" do + test_options_server('-v').must_match(/\ARack \d\.\d \(Release: \d+\.\d+\.\d+(\.\d+)?\)\nexited\z/) + end + + it "warn for invalid --profile-mode option" do + test_options_server('--profile-mode', 'foo').must_match(/\Ainvalid option: --profile-mode unknown profile mode: foo.*Usage: rackup/m) + end + + it "warn for invalid options" do + test_options_server('--banana').must_match(/\Ainvalid option: --banana.*Usage: rackup/m) + end + + it "support -b option to specify inline rackup config" do + SPEC_ARGV[0..-1] = ['-scgi', '-E', 'development', '-b', 'use Rack::ContentLength; run ->(env){[200, {}, []]}'] + server = Rack::Server.new + def (server.server).run(app, **) app end + s, h, b = server.start.call('rack.errors' => StringIO.new) + s.must_equal 500 + h['Content-Type'].must_equal 'text/plain' + b.join.must_include 'Rack::Lint::LintError' + end + + it "support -e option to evaluate ruby code" do + SPEC_ARGV[0..-1] = ['-scgi', '-e', 'Object::XYZ = 2'] + begin + server = Rack::Server.new + Object::XYZ.must_equal 2 + ensure + Object.send(:remove_const, :XYZ) + end + end + + it "abort if config file does not exist" do + SPEC_ARGV[0..-1] = ['-scgi'] + server = Rack::Server.new + def server.abort(s) throw :abort, s end + message = catch(:abort) do + server.start + end + message.must_match(/\Aconfiguration .*config\.ru not found/) + end + + it "support -I option to change the load path and -r to require" do + SPEC_ARGV[0..-1] = ['-scgi', '-Ifoo/bar', '-Itest/load', '-rrack-test-a', '-rrack-test-b'] + begin + server = Rack::Server.new + def (server.server).run(*) end + def server.handle_profiling(*) end + def server.app(*) end + server.start + $LOAD_PATH.must_include('foo/bar') + $LOAD_PATH.must_include('test/load') + $LOADED_FEATURES.must_include(File.join(Dir.pwd, "test/load/rack-test-a.rb")) + $LOADED_FEATURES.must_include(File.join(Dir.pwd, "test/load/rack-test-b.rb")) + ensure + $LOAD_PATH.delete('foo/bar') + $LOAD_PATH.delete('test/load') + $LOADED_FEATURES.delete(File.join(Dir.pwd, "test/load/rack-test-a.rb")) + $LOADED_FEATURES.delete(File.join(Dir.pwd, "test/load/rack-test-b.rb")) + end + end + + it "support -w option to warn and -d option to debug" do + SPEC_ARGV[0..-1] = ['-scgi', '-d', '-w'] + warn = $-w + debug = $DEBUG + begin + server = Rack::Server.new + def (server.server).run(*) end + def server.handle_profiling(*) end + def server.app(*) end + def server.p(*) end + def server.pp(*) end + def server.require(*) end + server.start + $-w.must_equal true + $DEBUG.must_equal true + ensure + $-w = warn + $DEBUG = debug + end + end + + if RUBY_ENGINE == "ruby" + it "support --heap option for heap profiling" do + begin + require 'objspace' + rescue LoadError + else + t = Tempfile.new + begin + SPEC_ARGV[0..-1] = ['-scgi', '--heap', t.path, '-E', 'production', '-b', 'run ->(env){[200, {}, []]}'] + server = Rack::Server.new + def (server.server).run(*) end + def server.exit; throw :exit end + catch :exit do + server.start + end + File.file?(t.path).must_equal true + ensure + File.delete t.path + end + end + end + + it "support --profile-mode option for stackprof profiling" do + begin + require 'stackprof' + rescue LoadError + else + t = Tempfile.new + begin + SPEC_ARGV[0..-1] = ['-scgi', '--profile', t.path, '--profile-mode', 'cpu', '-E', 'production', '-b', 'run ->(env){[200, {}, []]}'] + server = Rack::Server.new + def (server.server).run(*) end + def server.puts(*) end + def server.exit; throw :exit end + catch :exit do + server.start + end + File.file?(t.path).must_equal true + ensure + File.delete t.path + end + end + end + + it "support --profile-mode option for stackprof profiling without --profile option" do + begin + require 'stackprof' + rescue LoadError + else + begin + SPEC_ARGV[0..-1] = ['-scgi', '--profile-mode', 'cpu', '-E', 'production', '-b', 'run ->(env){[200, {}, []]}'] + server = Rack::Server.new + def (server.server).run(*) end + filename = nil + server.define_singleton_method(:make_profile_name) do |fname, &block| + super(fname) do |fn| + filename = fn + block.call(filename) + end + end + def server.puts(*) end + def server.exit; throw :exit end + catch :exit do + server.start + end + File.file?(filename).must_equal true + ensure + File.delete filename + end + end + end + end + + it "support exit for INT signal when server does not respond to shutdown" do + SPEC_ARGV[0..-1] = ['-scgi'] + server = Rack::Server.new + def (server.server).run(*) end + def server.handle_profiling(*) end + def server.app(*) end + exited = false + server.define_singleton_method(:exit) do + exited = true + end + server.start + exited.must_equal false + Process.kill(:INT, $$) + sleep 1 unless RUBY_ENGINE == 'ruby' + exited.must_equal true + end + + it "support support Server.start for starting" do + SPEC_ARGV[0..-1] = ['-scgi'] + c = Class.new(Rack::Server) do + def start(*) [self.class, :started] end + end + c.start.must_equal [c, :started] + end + + it "run a server" do pidfile = Tempfile.open('pidfile') { |f| break f } FileUtils.rm pidfile.path @@ -144,6 +371,41 @@ describe Rack::Server do open(pidfile.path) { |f| f.read.must_equal $$.to_s } end + it "run a secure server" do + pidfile = Tempfile.open('pidfile') { |f| break f } + FileUtils.rm pidfile.path + server = Rack::Server.new( + app: app, + environment: 'none', + pid: pidfile.path, + Port: TCPServer.open('127.0.0.1', 0){|s| s.addr[1] }, + Host: '127.0.0.1', + Logger: WEBrick::Log.new(nil, WEBrick::BasicLog::WARN), + AccessLog: [], + daemonize: false, + server: 'webrick', + SSLEnable: true, + SSLCertName: [['CN', 'nobody'], ['DC', 'example']] + ) + t = Thread.new { server.start { |s| Thread.current[:server] = s } } + t.join(0.01) until t[:server] && t[:server].status != :Stop + + uri = URI.parse("https://127.0.0.1:#{server.options[:Port]}/") + + Net::HTTP.start("127.0.0.1", uri.port, use_ssl: true, + verify_mode: OpenSSL::SSL::VERIFY_NONE) do |http| + + request = Net::HTTP::Get.new uri + + body = http.request(request).body + body.must_equal 'success' + end + + Process.kill(:INT, $$) + t.join + open(pidfile.path) { |f| f.read.must_equal $$.to_s } + end if RUBY_VERSION >= "2.6" + it "check pid file presence and running process" do pidfile = Tempfile.open('pidfile') { |f| f.write($$); break f }.path server = Rack::Server.new(pid: pidfile) @@ -172,7 +434,15 @@ describe Rack::Server do server.send(:pidfile_process_status).must_equal :not_owned end - it "not write pid file when it is created after check" do + it "rewrite pid file when it does not reference a running process" do + pidfile = Tempfile.open('pidfile') { |f| break f }.path + server = Rack::Server.new(pid: pidfile) + ::File.open(pidfile, 'w') { } + server.send(:write_pid) + ::File.read(pidfile).to_i.must_equal $$ + end + + it "not write pid file when it references a running process" do pidfile = Tempfile.open('pidfile') { |f| break f }.path ::File.delete(pidfile) server = Rack::Server.new(pid: pidfile) diff --git a/test/spec_session_abstract_id.rb b/test/spec_session_abstract_id.rb index 3591a3dea145f09e15465f149130040f51b99c71..17cdb3e55cd91855ee980f112eb2f53e3167cb06 100644 --- a/test/spec_session_abstract_id.rb +++ b/test/spec_session_abstract_id.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' +require_relative 'helper' ### WARNING: there be hax in this file. require 'rack/session/abstract/id' @@ -30,4 +30,53 @@ describe Rack::Session::Abstract::ID do id.send(:generate_sid).must_equal 'fake_hex' end + it "should warn when subclassing" do + verbose = $VERBOSE + begin + $VERBOSE = true + warn_arg = nil + @id.define_singleton_method(:warn) do |arg| + warn_arg = arg + end + c = Class.new(@id) + regexp = /is inheriting from Rack::Session::Abstract::ID. Inheriting from Rack::Session::Abstract::ID is deprecated, please inherit from Rack::Session::Abstract::Persisted instead/ + warn_arg.must_match(regexp) + + warn_arg = nil + c = Class.new(c) + warn_arg.must_be_nil + ensure + $VERBOSE = verbose + @id.singleton_class.send(:remove_method, :warn) + end + end + + it "#find_session should find session in request" do + id = @id.new(nil) + def id.get_session(env, sid) + [env['rack.session'], generate_sid] + end + req = Rack::Request.new('rack.session' => {}) + session, sid = id.find_session(req, nil) + session.must_equal({}) + sid.must_match(/\A\h+\z/) + end + + it "#write_session should write session to request" do + id = @id.new(nil) + def id.set_session(env, sid, session, options) + [env, sid, session, options] + end + req = Rack::Request.new({}) + id.write_session(req, 1, 2, 3).must_equal [{}, 1, 2, 3] + end + + it "#delete_session should remove session from request" do + id = @id.new(nil) + def id.destroy_session(env, sid, options) + [env, sid, options] + end + req = Rack::Request.new({}) + id.delete_session(req, 1, 2).must_equal [{}, 1, 2] + end end diff --git a/test/spec_session_abstract_persisted.rb b/test/spec_session_abstract_persisted.rb new file mode 100644 index 0000000000000000000000000000000000000000..84ddf0728f115268665ba218f556622294b285c2 --- /dev/null +++ b/test/spec_session_abstract_persisted.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require_relative 'helper' +require 'rack/session/abstract/id' + +describe Rack::Session::Abstract::Persisted do + def setup + @class = Rack::Session::Abstract::Persisted + @pers = @class.new(nil) + end + + it "#generated_sid generates a session identifier" do + @pers.send(:generate_sid).must_match(/\A\h+\z/) + @pers.send(:generate_sid, nil).must_match(/\A\h+\z/) + + obj = Object.new + def obj.hex(_); raise NotImplementedError end + @pers.send(:generate_sid, obj).must_match(/\A\h+\z/) + end + + it "#commit_session? returns false if :skip option is given" do + @pers.send(:commit_session?, Rack::Request.new({}), {}, skip: true).must_equal false + end + + it "#commit_session writes to rack.errors if session cannot be written" do + @pers = @class.new(nil) + def @pers.write_session(*) end + errors = StringIO.new + env = { 'rack.errors' => errors } + req = Rack::Request.new(env) + store = Class.new do + def load_session(req) + ["id", {}] + end + def session_exists?(req) + true + end + end + session = env['rack.session'] = Rack::Session::Abstract::SessionHash.new(store.new, req) + session['foo'] = 'bar' + @pers.send(:commit_session, req, Rack::Response.new) + errors.rewind + errors.read.must_equal "Warning! Rack::Session::Abstract::Persisted failed to save session. Content dropped.\n" + end + + it "#cookie_value returns its argument" do + obj = Object.new + @pers.send(:cookie_value, obj).must_equal(obj) + end + + it "#session_class returns the default session class" do + @pers.send(:session_class).must_equal Rack::Session::Abstract::SessionHash + end + + it "#find_session raises" do + proc { @pers.send(:find_session, nil, nil) }.must_raise RuntimeError + end + + it "#write_session raises" do + proc { @pers.send(:write_session, nil, nil, nil, nil) }.must_raise RuntimeError + end + + it "#delete_session raises" do + proc { @pers.send(:delete_session, nil, nil, nil) }.must_raise RuntimeError + end +end diff --git a/test/spec_session_persisted_secure_secure_session_hash.rb b/test/spec_session_abstract_persisted_secure_secure_session_hash.rb similarity index 94% rename from test/spec_session_persisted_secure_secure_session_hash.rb rename to test/spec_session_abstract_persisted_secure_secure_session_hash.rb index 21cbf8e6e5f544482b11d2a494545446a9050c31..1a007eb4ae1831dd2a1bc47c6d287c58901f5333 100644 --- a/test/spec_session_persisted_secure_secure_session_hash.rb +++ b/test/spec_session_abstract_persisted_secure_secure_session_hash.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' +require_relative 'helper' require 'rack/session/abstract/id' describe Rack::Session::Abstract::PersistedSecure::SecureSessionHash do @@ -56,7 +56,7 @@ describe Rack::Session::Abstract::PersistedSecure::SecureSessionHash do end it "works with a block" do - assert_equal :default, hash.fetch(:unkown) { :default } + assert_equal :default, hash.fetch(:unknown) { :default } end it "it raises when fetching unknown keys without defaults" do diff --git a/test/spec_session_abstract_session_hash.rb b/test/spec_session_abstract_session_hash.rb index 5d0d10ce4f23ef8a9ec087788ab140ad6a514f96..ac0b7bb3afed06b477ca35c78817610ad687a517 100644 --- a/test/spec_session_abstract_session_hash.rb +++ b/test/spec_session_abstract_session_hash.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' +require_relative 'helper' require 'rack/session/abstract/id' describe Rack::Session::Abstract::SessionHash do @@ -10,21 +10,70 @@ describe Rack::Session::Abstract::SessionHash do super store = Class.new do def load_session(req) - ["id", { foo: :bar, baz: :qux }] + ["id", { foo: :bar, baz: :qux, x: { y: 1 } }] end def session_exists?(req) true end end - @hash = Rack::Session::Abstract::SessionHash.new(store.new, nil) + @class = Rack::Session::Abstract::SessionHash + @hash = @class.new(store.new, nil) end - it "returns keys" do - assert_equal ["foo", "baz"], hash.keys + it ".find finds entry in request" do + assert_equal({}, @class.find(Rack::Request.new('rack.session' => {}))) end - it "returns values" do - assert_equal [:bar, :qux], hash.values + it ".set sets session in request" do + req = Rack::Request.new({}) + @class.set(req, {}) + req.env['rack.session'].must_equal({}) + end + + it ".set_options sets session options in request" do + req = Rack::Request.new({}) + h = {} + @class.set_options(req, h) + opts = req.env['rack.session.options'] + opts.must_equal(h) + opts.wont_be_same_as(h) + end + + it "#keys returns keys" do + assert_equal ["foo", "baz", "x"], hash.keys + end + + it "#values returns values" do + assert_equal [:bar, :qux, { y: 1 }], hash.values + end + + it "#dig operates like Hash#dig" do + assert_equal({ y: 1 }, hash.dig("x")) + assert_equal(1, hash.dig(:x, :y)) + assert_nil(hash.dig(:z)) + assert_nil(hash.dig(:x, :z)) + lambda { hash.dig(:x, :y, :z) }.must_raise TypeError + lambda { hash.dig }.must_raise ArgumentError + end + + it "#each iterates over entries" do + a = [] + @hash.each do |k, v| + a << [k, v] + end + a.must_equal [["foo", :bar], ["baz", :qux], ["x", { y: 1 }]] + end + + it "#has_key returns whether the key is in the hash" do + assert_equal true, hash.has_key?("foo") + assert_equal true, hash.has_key?(:foo) + assert_equal false, hash.has_key?("food") + assert_equal false, hash.has_key?(:food) + end + + it "#replace replaces hash" do + hash.replace({ bar: "foo" }) + assert_equal "foo", hash["bar"] end describe "#fetch" do @@ -37,7 +86,7 @@ describe Rack::Session::Abstract::SessionHash do end it "works with a block" do - assert_equal :default, hash.fetch(:unkown) { :default } + assert_equal :default, hash.fetch(:unknown) { :default } end it "it raises when fetching unknown keys without defaults" do @@ -45,9 +94,7 @@ describe Rack::Session::Abstract::SessionHash do end end - describe "#stringify_keys" do - it "returns hash or session hash with keys stringified" do - assert_equal({ "foo" => :bar, "baz" => :qux }, hash.send(:stringify_keys, hash).to_h) - end + it "#stringify_keys returns hash or session hash with keys stringified" do + assert_equal({ "foo" => :bar, "baz" => :qux, "x" => { y: 1 } }, hash.send(:stringify_keys, hash).to_h) end end diff --git a/test/spec_session_cookie.rb b/test/spec_session_cookie.rb index 57850239e55696c754d6087c3bdef668e1017b88..ce85ba321232a9ce2c7567acf7887d434612c2b9 100644 --- a/test/spec_session_cookie.rb +++ b/test/spec_session_cookie.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/session/cookie' -require 'rack/lint' -require 'rack/mock' +require_relative 'helper' describe Rack::Session::Cookie do incrementor = lambda do |env| @@ -199,6 +196,23 @@ describe Rack::Session::Cookie do response.body.must_equal '{"counter"=>1}' end + it "passes through same_site option to session cookie" do + response = response_for(app: [incrementor, same_site: :none]) + response["Set-Cookie"].must_include "SameSite=None" + end + + it "allows using a lambda to specify same_site option, because some browsers require different settings" do + # Details of why this might need to be set dynamically: + # https://www.chromium.org/updates/same-site/incompatible-clients + # https://gist.github.com/bnorton/7dee72023787f367c48b3f5c2d71540f + + response = response_for(app: [incrementor, same_site: lambda { |req, res| :none }]) + response["Set-Cookie"].must_include "SameSite=None" + + response = response_for(app: [incrementor, same_site: lambda { |req, res| :lax }]) + response["Set-Cookie"].must_include "SameSite=Lax" + end + it "loads from a cookie" do response = response_for(app: incrementor) diff --git a/test/spec_session_pool.rb b/test/spec_session_pool.rb index dd1b6573f74e1553312b5dcfe51cf4af8985cd43..aba93fb169bdec8cf51ff03897e0d74ff821c0e9 100644 --- a/test/spec_session_pool.rb +++ b/test/spec_session_pool.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'thread' -require 'rack/lint' -require 'rack/mock' -require 'rack/session/pool' +require_relative 'helper' describe Rack::Session::Pool do session_key = Rack::Session::Pool::DEFAULT_OPTIONS[:key] @@ -58,7 +54,7 @@ describe Rack::Session::Pool do body.must_equal '{"counter"=>3}' end - it "survives nonexistant cookies" do + it "survives nonexistent cookies" do pool = Rack::Session::Pool.new(incrementor) res = Rack::MockRequest.new(pool). get("/", "HTTP_COOKIE" => "#{session_key}=blarghfasel") @@ -182,6 +178,25 @@ describe Rack::Session::Pool do pool.pool[session_id.public_id].must_be_nil end + it "passes through same_site option to session pool" do + pool = Rack::Session::Pool.new(incrementor, same_site: :none) + req = Rack::MockRequest.new(pool) + res = req.get("/") + res["Set-Cookie"].must_include "SameSite=None" + end + + it "allows using a lambda to specify same_site option, because some browsers require different settings" do + pool = Rack::Session::Pool.new(incrementor, same_site: lambda { |req, res| :none }) + req = Rack::MockRequest.new(pool) + res = req.get("/") + res["Set-Cookie"].must_include "SameSite=None" + + pool = Rack::Session::Pool.new(incrementor, same_site: lambda { |req, res| :lax }) + req = Rack::MockRequest.new(pool) + res = req.get("/") + res["Set-Cookie"].must_include "SameSite=Lax" + end + # anyone know how to do this better? it "should merge sessions when multithreaded" do unless $DEBUG diff --git a/test/spec_show_exceptions.rb b/test/spec_show_exceptions.rb index a4ade121d5c844c0ada17534361b9caeefaf6c51..441599b4f86657323f15ab799c30d6315fe6d411 100644 --- a/test/spec_show_exceptions.rb +++ b/test/spec_show_exceptions.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/show_exceptions' -require 'rack/lint' -require 'rack/mock' +require_relative 'helper' describe Rack::ShowExceptions do def show_exceptions(app) @@ -25,6 +22,48 @@ describe Rack::ShowExceptions do assert_match(res, /RuntimeError/) assert_match(res, /ShowExceptions/) + assert_match(res, /No GET data/) + assert_match(res, /No POST data/) + end + + it "handles exceptions with backtrace lines for files that are not readable" do + res = nil + + req = Rack::MockRequest.new( + show_exceptions( + lambda{|env| raise RuntimeError, "foo", ["nonexistant.rb:2:in `a': adf (RuntimeError)", "bad-backtrace"] } + )) + + res = req.get("/", "HTTP_ACCEPT" => "text/html") + + res.must_be :server_error? + res.status.must_equal 500 + + assert_includes(res.body, 'RuntimeError') + assert_includes(res.body, 'ShowExceptions') + assert_includes(res.body, 'No GET data') + assert_includes(res.body, 'No POST data') + assert_includes(res.body, 'nonexistant.rb') + refute_includes(res.body, 'bad-backtrace') + end + + it "handles invalid POST data exceptions" do + res = nil + + req = Rack::MockRequest.new( + show_exceptions( + lambda{|env| raise RuntimeError } + )) + + res = req.post("/", "HTTP_ACCEPT" => "text/html", "rack.input" => StringIO.new(String.new << '(%bad-params%)')) + + res.must_be :server_error? + res.status.must_equal 500 + + assert_match(res, /RuntimeError/) + assert_match(res, /ShowExceptions/) + assert_match(res, /No GET data/) + assert_match(res, /Invalid POST data/) end it "works with binary data in the Rack environment" do diff --git a/test/spec_show_status.rb b/test/spec_show_status.rb index ca23134e0598ed424b5d68e8a3fa808755342b57..486076b8d5b0c004e068334bd01950bc05002b1b 100644 --- a/test/spec_show_status.rb +++ b/test/spec_show_status.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/show_status' -require 'rack/lint' -require 'rack/mock' -require 'rack/utils' +require_relative 'helper' describe Rack::ShowStatus do def show_status(app) @@ -44,6 +40,24 @@ describe Rack::ShowStatus do assert_match(res, /too meta/) end + it "let the app provide additional information with non-String details" do + req = Rack::MockRequest.new( + show_status( + lambda{|env| + env["rack.showstatus.detail"] = ['gone too meta.'] + [404, { "Content-Type" => "text/plain", "Content-Length" => "0" }, []] + })) + + res = req.get("/", lint: true) + res.must_be :not_found? + res.wont_be_empty + + res["Content-Type"].must_equal "text/html" + assert_includes(res.body, '404') + assert_includes(res.body, 'Not Found') + assert_includes(res.body, '["gone too meta."]') + end + it "escape error" do detail = "<script>alert('hi \"')</script>" req = Rack::MockRequest.new( diff --git a/test/spec_static.rb b/test/spec_static.rb index d33e8edcbf143ec3a8e4316683f756e63d935579..2a94d68cafbd75522123e42b5218a38393a5d5c9 100644 --- a/test/spec_static.rb +++ b/test/spec_static.rb @@ -1,11 +1,7 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/static' -require 'rack/lint' -require 'rack/mock' +require_relative 'helper' require 'zlib' -require 'stringio' class DummyApp def call(env) @@ -14,6 +10,8 @@ class DummyApp end describe Rack::Static do + DOCROOT = File.expand_path(File.dirname(__FILE__)) unless defined? DOCROOT + def static(app, *args) Rack::Lint.new Rack::Static.new(app, *args) end @@ -21,6 +19,7 @@ describe Rack::Static do root = File.expand_path(File.dirname(__FILE__)) OPTIONS = { urls: ["/cgi"], root: root } + CASCADE_OPTIONS = { urls: ["/cgi"], root: root, cascade: true } STATIC_OPTIONS = { urls: [""], root: "#{root}/static", index: 'index.html' } STATIC_URLS_OPTIONS = { urls: ["/static"], root: "#{root}", index: 'index.html' } HASH_OPTIONS = { urls: { "/cgi/sekret" => 'cgi/test' }, root: root } @@ -29,6 +28,7 @@ describe Rack::Static do before do @request = Rack::MockRequest.new(static(DummyApp.new, OPTIONS)) + @cascade_request = Rack::MockRequest.new(static(DummyApp.new, CASCADE_OPTIONS)) @static_request = Rack::MockRequest.new(static(DummyApp.new, STATIC_OPTIONS)) @static_urls_request = Rack::MockRequest.new(static(DummyApp.new, STATIC_URLS_OPTIONS)) @hash_request = Rack::MockRequest.new(static(DummyApp.new, HASH_OPTIONS)) @@ -48,6 +48,18 @@ describe Rack::Static do res.must_be :not_found? end + it "serves files when using :cascade option" do + res = @cascade_request.get("/cgi/test") + res.must_be :ok? + res.body.must_match(/ruby/) + end + + it "calls down the chain if if can't find the file when using the :cascade option" do + res = @cascade_request.get("/cgi/foo") + res.must_be :ok? + res.body.must_equal "Hello World" + end + it "calls down the chain if url root is not known" do res = @request.get("/something/else") res.must_be :ok? @@ -124,6 +136,15 @@ describe Rack::Static do res.body.must_match(/ruby/) end + it "returns 304 if gzipped file isn't modified since last serve" do + path = File.join(DOCROOT, "/cgi/test") + res = @gzip_request.get("/cgi/test", 'HTTP_IF_MODIFIED_SINCE' => File.mtime(path).httpdate) + res.status.must_equal 304 + res.body.must_be :empty? + res.headers['Content-Encoding'].must_be_nil + res.headers['Content-Type'].must_be_nil + end + it "supports serving fixed cache-control (legacy option)" do opts = OPTIONS.merge(cache_control: 'public') request = Rack::MockRequest.new(static(DummyApp.new, opts)) @@ -138,7 +159,8 @@ describe Rack::Static do [%w(png jpg), { 'Cache-Control' => 'public, max-age=300' }], ['/cgi/assets/folder/', { 'Cache-Control' => 'public, max-age=400' }], ['cgi/assets/javascripts', { 'Cache-Control' => 'public, max-age=500' }], - [/\.(css|erb)\z/, { 'Cache-Control' => 'public, max-age=600' }] + [/\.(css|erb)\z/, { 'Cache-Control' => 'public, max-age=600' }], + [false, { 'Cache-Control' => 'public, max-age=600' }] ] } it "supports header rule :all" do diff --git a/test/spec_tempfile_reaper.rb b/test/spec_tempfile_reaper.rb index 0e7de841503714ee113e0b0f6f496cfbcb7fa433..063687a094aae61c490b53a50e354ec9de8d4006 100644 --- a/test/spec_tempfile_reaper.rb +++ b/test/spec_tempfile_reaper.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/tempfile_reaper' -require 'rack/lint' -require 'rack/mock' +require_relative 'helper' describe Rack::TempfileReaper do class MockTempfile diff --git a/test/spec_thin.rb b/test/spec_thin.rb index 0729c3f3c39f7d29975efff20fabd984f69241ac..f7a1211027d0ce037af68ea84b1cce278fc3fa78 100644 --- a/test/spec_thin.rb +++ b/test/spec_thin.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' +require_relative 'helper' begin require 'rack/handler/thin' -require File.expand_path('../testrequest', __FILE__) +require_relative 'testrequest' require 'timeout' describe Rack::Handler::Thin do diff --git a/test/spec_urlmap.rb b/test/spec_urlmap.rb index 9ce382987f56266bb28e2a85f924d37c6a04d5ee..29af5587052f26291da3cefb18ff178f6a7ef33d 100644 --- a/test/spec_urlmap.rb +++ b/test/spec_urlmap.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/urlmap' -require 'rack/mock' +require_relative 'helper' describe Rack::URLMap do it "dispatches paths correctly" do @@ -244,4 +242,10 @@ describe Rack::URLMap do res["X-PathInfo"].must_equal "/" res["X-ScriptName"].must_equal "" end + + it "not allow locations unless they start with /" do + lambda do + Rack::URLMap.new("a/" => lambda { |env| }) + end.must_raise ArgumentError + end end diff --git a/test/spec_utils.rb b/test/spec_utils.rb index 5fd9224113847a1b4b784cb7d6ab84c5bb36eb75..90676258fd9783b2203011812c3f834af1003d37 100644 --- a/test/spec_utils.rb +++ b/test/spec_utils.rb @@ -1,24 +1,22 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/utils' -require 'rack/mock' +require_relative 'helper' require 'timeout' describe Rack::Utils do - def assert_sets exp, act + def assert_sets(exp, act) exp = Set.new exp.split '&' act = Set.new act.split '&' assert_equal exp, act end - def assert_query exp, act + def assert_query(exp, act) assert_sets exp, Rack::Utils.build_query(act) end - def assert_nested_query exp, act + def assert_nested_query(exp, act) assert_sets exp, Rack::Utils.build_nested_query(act) end @@ -36,7 +34,7 @@ describe Rack::Utils do it "round trip binary data" do r = [218, 0].pack 'CC' - z = Rack::Utils.unescape(Rack::Utils.escape(r), Encoding::BINARY) + z = Rack::Utils.unescape(Rack::Utils.escape(r), Encoding::BINARY) r.must_equal z end @@ -106,6 +104,12 @@ describe Rack::Utils do Rack::Utils.parse_query(",foo=bar;,", ";,").must_equal "foo" => "bar" end + it "parse query strings correctly using arrays" do + Rack::Utils.parse_query("a[]=1").must_equal "a[]" => "1" + Rack::Utils.parse_query("a[]=1&a[]=2").must_equal "a[]" => ["1", "2"] + Rack::Utils.parse_query("a[]=1&a[]=2&a[]=3").must_equal "a[]" => ["1", "2", "3"] + end + it "not create infinite loops with cycle structures" do ex = { "foo" => nil } ex["foo"] = ex @@ -124,11 +128,17 @@ describe Rack::Utils do lambda { Rack::Utils.parse_nested_query("foo#{"[a]" * len}=bar") - }.must_raise(RangeError) + }.must_raise(Rack::QueryParser::ParamsTooDeepError) Rack::Utils.parse_nested_query("foo#{"[a]" * (len - 1)}=bar") end + # ParamsTooDeepError was introduced in the middle of 2.2 releases + # and this test is here to ensure backwards compatibility + it "ParamsTooDeepError is inherited from originally used RangeError" do + (Rack::QueryParser::ParamsTooDeepError < RangeError).must_equal(true) + end + it "parse nested query strings correctly" do Rack::Utils.parse_nested_query("foo"). must_equal "foo" => nil @@ -254,8 +264,12 @@ describe Rack::Utils do end end Rack::Utils.default_query_parser = Rack::QueryParser.new(param_parser_class, 65536, 100) - Rack::Utils.parse_query(",foo=bar;,", ";,")[:foo].must_equal "bar" - Rack::Utils.parse_nested_query("x[y][][z]=1&x[y][][w]=2")[:x][:y][0][:z].must_equal "1" + h1 = Rack::Utils.parse_query(",foo=bar;,", ";,") + h1[:foo].must_equal "bar" + h2 = Rack::Utils.parse_nested_query("x[y][][z]=1&x[y][][w]=2") + h2[:x][:y][0][:z].must_equal "1" + h3 = Rack::Utils.parse_nested_query("") + h3.merge(h1)[:foo].must_equal "bar" ensure Rack::Utils.default_query_parser = default_parser end @@ -436,6 +450,7 @@ describe Rack::Utils do helper.call(%w(compress gzip identity), [["compress", 1.0], ["gzip", 1.0]]).must_equal "compress" helper.call(%w(compress gzip identity), [["compress", 0.5], ["gzip", 1.0]]).must_equal "gzip" + helper.call(%w(compress gzip identity), [["gzip", 1.0], ["compress", 1.0]]).must_equal "compress" helper.call(%w(foo bar identity), []).must_equal "identity" helper.call(%w(foo bar identity), [["*", 1.0]]).must_equal "foo" @@ -510,6 +525,9 @@ describe Rack::Utils, "cookies" do env = Rack::MockRequest.env_for("", "HTTP_COOKIE" => "foo=bar;quux=h&m") Rack::Utils.parse_cookies(env).must_equal({ "foo" => "bar", "quux" => "h&m" }) + env = Rack::MockRequest.env_for("", "HTTP_COOKIE" => "foo=bar; quux=h&m") + Rack::Utils.parse_cookies(env).must_equal({ "foo" => "bar", "quux" => "h&m" }) + env = Rack::MockRequest.env_for("", "HTTP_COOKIE" => "foo=bar").freeze Rack::Utils.parse_cookies(env).must_equal({ "foo" => "bar" }) @@ -545,6 +563,30 @@ describe Rack::Utils, "cookies" do Rack::Utils.add_cookie_to_header(Object.new, 'name', 'value') }.must_raise ArgumentError end + + it "sets and deletes cookies in header hash" do + header = { 'Set-Cookie' => '' } + Rack::Utils.set_cookie_header!(header, 'name', 'value').must_be_nil + header['Set-Cookie'].must_equal 'name=value' + Rack::Utils.set_cookie_header!(header, 'name2', 'value2').must_be_nil + header['Set-Cookie'].must_equal "name=value\nname2=value2" + Rack::Utils.set_cookie_header!(header, 'name2', 'value3').must_be_nil + header['Set-Cookie'].must_equal "name=value\nname2=value2\nname2=value3" + + Rack::Utils.delete_cookie_header!(header, 'name2').must_be_nil + header['Set-Cookie'].must_equal "name=value\nname2=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT" + Rack::Utils.delete_cookie_header!(header, 'name').must_be_nil + header['Set-Cookie'].must_equal "name2=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT\nname=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT" + + header = { 'Set-Cookie' => nil } + Rack::Utils.delete_cookie_header!(header, 'name').must_be_nil + header['Set-Cookie'].must_equal "name=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT" + + header = { 'Set-Cookie' => [] } + Rack::Utils.delete_cookie_header!(header, 'name').must_be_nil + header['Set-Cookie'].must_equal "name=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT" + end + end describe Rack::Utils, "byte_range" do @@ -670,7 +712,7 @@ describe Rack::Utils::HeaderHash do h.delete("Foo").must_equal "bar" end - it "return nil when #delete is called on a non-existant key" do + it "return nil when #delete is called on a non-existent key" do h = Rack::Utils::HeaderHash.new("foo" => "bar") h.delete("Hello").must_be_nil end @@ -696,14 +738,43 @@ describe Rack::Utils::HeaderHash do h['foo'].must_be_nil h.wont_include 'foo' end + + it "uses memoized header hash" do + env = {} + headers = Rack::Utils::HeaderHash.new({ 'content-type' => "text/plain", "content-length" => "3" }) + + app = lambda do |env| + [200, headers, []] + end + + app = Rack::ContentLength.new(app) + + response = app.call(env) + assert_same response[1], headers + end + + it "duplicates header hash" do + env = {} + headers = Rack::Utils::HeaderHash.new({ 'content-type' => "text/plain", "content-length" => "3" }) + headers.freeze + + app = lambda do |env| + [200, headers, []] + end + + app = Rack::ContentLength.new(app) + + response = app.call(env) + refute_same response[1], headers + end end describe Rack::Utils::Context do class ContextTest attr_reader :app - def initialize app; @app = app; end - def call env; context env; end - def context env, app = @app; app.call(env); end + def initialize(app); @app = app; end + def call(env); context env; end + def context(env, app = @app); app.call(env); end end test_target1 = proc{|e| e.to_s + ' world' } test_target2 = proc{|e| e.to_i + 2 } @@ -744,6 +815,8 @@ describe Rack::Utils::Context do r2.must_equal 4 r3 = c3.call(:misc_symbol) r3.must_be_nil + r3 = c2.context(:misc_symbol, test_target3) + r3.must_be_nil r4 = Rack::MockRequest.new(a4).get('/') r4.status.must_equal 200 r5 = Rack::MockRequest.new(a5).get('/') diff --git a/test/spec_version.rb b/test/spec_version.rb index d4191aa41724bd0e5fae767718a0855b4818bd25..68c4b4c72815672df01c11922cbd1817b8d772d2 100644 --- a/test/spec_version.rb +++ b/test/spec_version.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack' +require_relative 'helper' describe Rack do describe 'version' do diff --git a/test/spec_webrick.rb b/test/spec_webrick.rb index 0d0aa8f7fabe81b4b6ecaa66875bdeba59ff29d9..a3c324a90e531e62c9cabb947e76430ab558eeba 100644 --- a/test/spec_webrick.rb +++ b/test/spec_webrick.rb @@ -1,9 +1,8 @@ # frozen_string_literal: true -require 'minitest/global_expectations/autorun' -require 'rack/mock' +require_relative 'helper' require 'thread' -require File.expand_path('../testrequest', __FILE__) +require_relative 'testrequest' Thread.abort_on_exception = true @@ -125,11 +124,10 @@ describe Rack::Handler::WEBrick do t = Thread.new do Rack::Handler::WEBrick.run(lambda {}, - { Host: '127.0.0.1', Port: 9210, Logger: WEBrick::Log.new(nil, WEBrick::BasicLog::WARN), - AccessLog: [] }) { |server| + AccessLog: []) { |server| assert_kind_of WEBrick::HTTPServer, server queue.push(server) } diff --git a/test/testrequest.rb b/test/testrequest.rb index aabe7fa6b342ad068fdec7b7cec94271df1ebe00..b85aae8312dafedbfa6a64b48a84b8cdb8661b06 100644 --- a/test/testrequest.rb +++ b/test/testrequest.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'yaml' +require_relative 'psych_fix' require 'net/http' require 'rack/lint' @@ -42,7 +43,7 @@ class TestRequest http.request(get) { |response| @status = response.code.to_i begin - @response = YAML.load(response.body) + @response = YAML.unsafe_load(response.body) rescue TypeError, ArgumentError @response = nil end @@ -60,7 +61,7 @@ class TestRequest post.basic_auth user, passwd if user && passwd http.request(post) { |response| @status = response.code.to_i - @response = YAML.load(response.body) + @response = YAML.unsafe_load(response.body) } } end