wsv 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3439ce5afffd7bfe4dc3e2a18da2d3f9d15ffd10b28e9cc4bb33f5c3e14ada12
4
- data.tar.gz: 98e65904c83834cc8e910931f1884f52e652346bad94612a8ba31933e607ff8c
3
+ metadata.gz: 3d932b12a288921b6935081095a656825c9efaaabb9d086350104c6d92c76d42
4
+ data.tar.gz: 91c097132033a2030f4e5e9f8f4aa9c799078e7006320cbfff566ea88ae6c1b9
5
5
  SHA512:
6
- metadata.gz: 382457dc4151007dca51ab8a550021b06bfe4f4127d5ff40c3c9da7e566b3987ec16b57838b8ce5e30a210d538b87231b9f0f0e5e8b5ef91ef20cc5fdc8f9395
7
- data.tar.gz: 2b6210d0c5063c4ba5a8b6c3d723cfe5db3abe9c41b20b6c56a99d64cac0a9b961d694fe2a7b2458250441102ed959034f13f7be169fa0a8102ef46405d8a4f4
6
+ metadata.gz: a272e137f58400e80dd58d4d32f6dd1f998ee5eb148c281d95674be1b087ea72c966ea4d3b84381b28589181d0e3e1a787accea3133e899f2dd8490c04ace055
7
+ data.tar.gz: 7250c73ce5e90e6c85d598b5966bdc01467bc5aeff6869c3e63e2031a8f7c7d2baa8d873f0e347bf76d55e7e567787ba041cd3c4b790a2d192b5ff8fc806d2de
data/CHANGELOG.md CHANGED
@@ -1,6 +1,39 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.9.0
4
+
5
+ - Normalize the redirect `Location` to an origin-form path. Previously, an
6
+ absolute-form request target such as `GET http://example.test/docs HTTP/1.1`
7
+ produced `Location: http://example.test/docs/`; now it always emits
8
+ `Location: /docs/`.
9
+ - Reject control characters (C0 0x00-0x1F and 0x7F DEL) in the
10
+ decoded request path with `400`. RFC 3986 disallows them in URL paths;
11
+ this prevents NUL-byte `ArgumentError` from leaking out of
12
+ `Wsv::PathResolver` and provides defence-in-depth against CR/LF
13
+ smuggling alongside the existing response-header validation.
14
+ - Document the local-FS TOCTOU limitation in README's security model:
15
+ another local process with write access to the served directory can swap
16
+ files between path resolution and `File.open`. This is acknowledged as
17
+ out-of-scope for a development tool.
18
+ - Decrement the in-flight connection counter when `Thread.new` itself raises
19
+ `ThreadError` (e.g. OS thread limit reached). The dispatch returns `503`
20
+ for the rejected client and the server continues accepting subsequent
21
+ connections instead of permanently leaking a slot.
22
+ - Stream file responses through `IO.copy_stream` instead of buffering the
23
+ whole file in memory. Reduces RSS for large files and uses `sendfile(2)`
24
+ on Linux when available. `Response#body` still materializes to a String
25
+ for callers; the change is internal to the wire path.
26
+ - Support `Range` requests for static files (`206 Partial Content` with
27
+ `Content-Range`). Open-ended (`bytes=N-`), suffix (`bytes=-N`), and
28
+ bounded (`bytes=N-M`) forms are supported. Unsatisfiable ranges return
29
+ `416`; invalid syntax falls through to a normal `200`.
30
+ - Honour `If-Modified-Since` and return `304 Not Modified` when the file's
31
+ mtime (truncated to seconds) is at or before the supplied date.
32
+ - Advertise `Accept-Ranges: bytes` on `200` and `206` file responses.
33
+ - Document the public API contract in README: the CLI is the SemVer
34
+ surface. Ruby classes under `lib/wsv/` are implementation details.
35
+
36
+ ## 0.8.0
4
37
 
5
38
  - Bound request size: 8 KiB request line, 8 KiB per header line, 16 KiB total
6
39
  headers, 100 header lines. Returns 414 / 431 when exceeded.
data/README.md CHANGED
@@ -46,8 +46,10 @@ Options:
46
46
  - Serves `index.html` for directories that contain it.
47
47
  - Does not render directory listings.
48
48
  - Supports `GET` and `HEAD`.
49
+ - Supports `Range` requests (`206 Partial Content` with `Content-Range`).
50
+ - Honours `If-Modified-Since` and returns `304 Not Modified` when applicable.
49
51
  - Rejects paths that resolve outside the served directory.
50
- - Sends `Cache-Control: no-cache` for local development.
52
+ - Sends `Cache-Control: no-cache` so the browser revalidates each request.
51
53
 
52
54
  ## Security model
53
55
 
@@ -84,14 +86,41 @@ Within that scope it tries to behave defensively:
84
86
 
85
87
  - Authentication, authorization, or rate limiting.
86
88
  - TLS / HTTPS.
87
- - Range requests, conditional `GET`, or HTTP keep-alive.
89
+ - HTTP keep-alive (each response sets `Connection: close`).
90
+ - ETags / `If-None-Match`.
88
91
  - Production-grade DoS resistance under hostile network load.
92
+ - Defend against TOCTOU attacks from other local processes that can write
93
+ to the served directory. Path resolution (canonicalisation, dotfile
94
+ checks, within-root verification) happens before each file is opened;
95
+ another process that can swap files in the served directory between
96
+ resolution and read could redirect a request elsewhere on the same
97
+ machine.
89
98
  - Protect a directory you should not be sharing in the first place. The
90
99
  bound is the directory you pass on the command line; if it contains
91
100
  secrets, do not run `wsv` against it.
92
101
 
93
102
  If you need any of the above, use a real production server.
94
103
 
104
+ ## Public API and stability
105
+
106
+ `wsv` follows [Semantic Versioning](https://semver.org/). The public API
107
+ that SemVer covers is the CLI:
108
+
109
+ - The flags listed above (`-h` / `--host`, `-p` / `--port`, `--help`,
110
+ `--version`) and their meanings.
111
+ - The directory argument and the default behaviour when it is omitted.
112
+ - Process exit codes (`0` for success, `1` for usage / setup errors).
113
+
114
+ Within a major version, `wsv` will not silently change the default bind
115
+ host, default port, the dotfile-blocking rule, or the security posture in
116
+ ways that would surprise an existing user.
117
+
118
+ The Ruby classes inside `lib/wsv/` (`Wsv::Server`, `Wsv::App`,
119
+ `Wsv::PathResolver`, `Wsv::Request`, `Wsv::Response`, `Wsv::MimeTypes`,
120
+ `Wsv::Status`) are implementation details. They may change at any
121
+ time, including in patch releases. If you want to embed `wsv` as a
122
+ library, pin a specific version.
123
+
95
124
  ## License
96
125
 
97
126
  MIT
data/lib/wsv/app.rb CHANGED
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+ require "uri"
3
5
  require_relative "path_resolver"
4
6
  require_relative "response"
5
7
 
6
8
  module Wsv
7
9
  class App
8
10
  ALLOWED_METHODS = %w[GET HEAD].freeze
11
+ RANGE_PATTERN = /\Abytes=(\d+)?-(\d+)?\z/
9
12
 
10
13
  def initialize(root)
11
14
  @resolver = PathResolver.new(root)
@@ -24,15 +27,80 @@ module Wsv
24
27
  return Response.text(result.status, head: head) if result.error?
25
28
  return Response.redirect(redirect_location(raw_path, query), head: head) if result.redirect?
26
29
 
27
- Response.file(result.file, head: head)
30
+ file_response(result.file, request, head: head)
28
31
  end
29
32
 
30
33
  private
31
34
 
35
+ def file_response(file, request, head:)
36
+ return Response.not_modified if not_modified?(file, request.headers["if-modified-since"])
37
+
38
+ size = File.size(file)
39
+ range = parse_range(request.headers["range"], size)
40
+ case range
41
+ when :unsatisfiable
42
+ Response.range_not_satisfiable(size, head: head)
43
+ when nil
44
+ Response.file(file, head: head)
45
+ else
46
+ Response.file(file, head: head, range: range)
47
+ end
48
+ end
49
+
50
+ def not_modified?(file, header_value)
51
+ return false unless header_value
52
+
53
+ since = Time.httpdate(header_value)
54
+ File.mtime(file).to_i <= since.to_i
55
+ rescue ArgumentError
56
+ false
57
+ end
58
+
59
+ def parse_range(header_value, file_size)
60
+ return nil if header_value.nil? || header_value.empty?
61
+
62
+ match = header_value.match(RANGE_PATTERN)
63
+ return nil unless match
64
+
65
+ first, last = match.captures
66
+ if first.nil? && last.nil?
67
+ nil
68
+ elsif first.nil?
69
+ suffix_range(last.to_i, file_size)
70
+ elsif last.nil?
71
+ open_range(first.to_i, file_size)
72
+ else
73
+ bounded_range(first.to_i, last.to_i, file_size)
74
+ end
75
+ end
76
+
77
+ def suffix_range(suffix, file_size)
78
+ return :unsatisfiable if suffix.zero? || file_size.zero?
79
+
80
+ [file_size - suffix, 0].max..(file_size - 1)
81
+ end
82
+
83
+ def open_range(first, file_size)
84
+ return :unsatisfiable if first >= file_size
85
+
86
+ first..(file_size - 1)
87
+ end
88
+
89
+ def bounded_range(first, last, file_size)
90
+ return :unsatisfiable if first > last || first >= file_size
91
+
92
+ last = file_size - 1 if last >= file_size
93
+ first..last
94
+ end
95
+
32
96
  def redirect_location(raw_path, query)
33
- location = raw_path.end_with?("/") ? raw_path : "#{raw_path}/"
97
+ path = URI(raw_path.to_s).path
98
+ path = "/" if path.empty?
99
+ location = path.end_with?("/") ? path : "#{path}/"
34
100
  location += "?#{query}" if query && !query.empty?
35
101
  location
102
+ rescue URI::InvalidURIError
103
+ "/"
36
104
  end
37
105
  end
38
106
  end
@@ -4,6 +4,10 @@ require "uri"
4
4
 
5
5
  module Wsv
6
6
  class PathResolver
7
+ # RFC 3986 disallows control characters in URL paths. Reject them after
8
+ # percent-decoding so callers cannot smuggle CR/LF, NUL, etc. through.
9
+ INVALID_PATH_CHARS = /[\u0000-\u001f\u007f]/
10
+
7
11
  class Result
8
12
  attr_reader :status, :file
9
13
 
@@ -77,7 +81,10 @@ module Wsv
77
81
 
78
82
  def decode(raw_path)
79
83
  path = URI(raw_path.to_s).path
80
- percent_decode(path)
84
+ decoded = percent_decode(path)
85
+ return nil if decoded.nil? || decoded.match?(INVALID_PATH_CHARS)
86
+
87
+ decoded
81
88
  rescue URI::InvalidURIError
82
89
  nil
83
90
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Request
5
+ class Parser
6
+ REQUEST_LINE_LIMIT = 8192
7
+ HEADER_LINE_LIMIT = 8192
8
+ HEADER_COUNT_LIMIT = 100
9
+ HEADER_TOTAL_LIMIT = 16_384
10
+
11
+ def initialize(io)
12
+ @io = io
13
+ end
14
+
15
+ def parse
16
+ line = @io.gets(REQUEST_LINE_LIMIT)
17
+ return :empty unless line
18
+ raise TooLarge, 414 if line.bytesize >= REQUEST_LINE_LIMIT && !line.end_with?("\n")
19
+
20
+ method, target, version = line.split(/\s+/, 3)
21
+ version = version&.strip
22
+ return :malformed unless method && target && version&.start_with?("HTTP/")
23
+
24
+ Request.new(method: method, target: target, version: version, headers: read_headers)
25
+ end
26
+
27
+ private
28
+
29
+ def read_headers
30
+ headers = {}
31
+ total = 0
32
+ count = 0
33
+ while (line = @io.gets(HEADER_LINE_LIMIT))
34
+ raise TooLarge, 431 if line.bytesize >= HEADER_LINE_LIMIT && !line.end_with?("\n")
35
+
36
+ stripped = line.delete_suffix("\r\n").delete_suffix("\n").delete_suffix("\r")
37
+ break if stripped.empty?
38
+
39
+ count += 1
40
+ raise TooLarge, 431 if count > HEADER_COUNT_LIMIT
41
+
42
+ total += line.bytesize
43
+ raise TooLarge, 431 if total > HEADER_TOTAL_LIMIT
44
+
45
+ name, value = stripped.split(":", 2)
46
+ headers[name.downcase] = value.strip if name && value
47
+ end
48
+ headers
49
+ end
50
+ end
51
+ end
52
+ end
data/lib/wsv/request.rb CHANGED
@@ -1,12 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "request/parser"
4
+
3
5
  module Wsv
4
6
  class Request
5
- REQUEST_LINE_LIMIT = 8192
6
- HEADER_LINE_LIMIT = 8192
7
- HEADER_COUNT_LIMIT = 100
8
- HEADER_TOTAL_LIMIT = 16384
9
-
10
7
  class TooLarge < StandardError
11
8
  attr_reader :status_code
12
9
 
@@ -30,39 +27,7 @@ module Wsv
30
27
  end
31
28
 
32
29
  def self.parse(io)
33
- line = io.gets(REQUEST_LINE_LIMIT)
34
- return :empty unless line
35
- raise TooLarge, 414 if line.bytesize >= REQUEST_LINE_LIMIT && !line.end_with?("\n")
36
-
37
- method, target, version = line.split(/\s+/, 3)
38
- version = version&.strip
39
- return :malformed unless method && target && version&.start_with?("HTTP/")
40
-
41
- headers = read_headers(io)
42
- new(method: method, target: target, version: version, headers: headers)
43
- end
44
-
45
- def self.read_headers(io)
46
- headers = {}
47
- total = 0
48
- count = 0
49
- while (line = io.gets(HEADER_LINE_LIMIT))
50
- raise TooLarge, 431 if line.bytesize >= HEADER_LINE_LIMIT && !line.end_with?("\n")
51
-
52
- stripped = line.delete_suffix("\r\n").delete_suffix("\n").delete_suffix("\r")
53
- break if stripped.empty?
54
-
55
- count += 1
56
- raise TooLarge, 431 if count > HEADER_COUNT_LIMIT
57
-
58
- total += line.bytesize
59
- raise TooLarge, 431 if total > HEADER_TOTAL_LIMIT
60
-
61
- name, value = stripped.split(":", 2)
62
- headers[name.downcase] = value.strip if name && value
63
- end
64
- headers
30
+ Parser.new(io).parse
65
31
  end
66
- private_class_method :read_headers
67
32
  end
68
33
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Response
5
+ class FileBody
6
+ def initialize(path, offset: 0, length: nil)
7
+ @path = path
8
+ @offset = offset
9
+ @length = length || (File.size(path) - offset)
10
+ end
11
+
12
+ def to_s
13
+ File.open(@path, "rb") do |f|
14
+ f.seek(@offset)
15
+ f.read(@length)
16
+ end
17
+ end
18
+
19
+ def bytesize
20
+ @length
21
+ end
22
+
23
+ def write_to(io)
24
+ File.open(@path, "rb") do |f|
25
+ f.seek(@offset)
26
+ IO.copy_stream(f, io, @length)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../mime_types"
5
+
6
+ module Wsv
7
+ class Response
8
+ class FileBuilder
9
+ def initialize(path, head: false, range: nil)
10
+ @path = path
11
+ @head = head
12
+ @range = range
13
+ end
14
+
15
+ def build
16
+ if @range
17
+ Response.new(status: 206, headers: range_headers, body: range_body)
18
+ else
19
+ Response.new(status: 200, headers: full_headers, body: full_body)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def size
26
+ @size ||= File.size(@path)
27
+ end
28
+
29
+ def base_headers
30
+ {
31
+ "Content-Type" => MimeTypes.for_file(@path),
32
+ "Last-Modified" => File.mtime(@path).httpdate,
33
+ "Cache-Control" => "no-cache",
34
+ "Accept-Ranges" => "bytes"
35
+ }
36
+ end
37
+
38
+ def range_headers
39
+ base_headers.merge(
40
+ "Content-Length" => @range.size.to_s,
41
+ "Content-Range" => "bytes #{@range.begin}-#{@range.end}/#{size}"
42
+ )
43
+ end
44
+
45
+ def full_headers
46
+ base_headers.merge("Content-Length" => size.to_s)
47
+ end
48
+
49
+ def range_body
50
+ return StringBody.new("") if @head
51
+
52
+ FileBody.new(@path, offset: @range.begin, length: @range.size)
53
+ end
54
+
55
+ def full_body
56
+ return StringBody.new("") if @head
57
+
58
+ FileBody.new(@path)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Response
5
+ class StringBody
6
+ def initialize(string)
7
+ @string = string
8
+ end
9
+
10
+ def to_s
11
+ @string
12
+ end
13
+
14
+ def bytesize
15
+ @string.bytesize
16
+ end
17
+
18
+ def write_to(io)
19
+ io.write(@string) unless @string.empty?
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wsv
4
+ class Response
5
+ class TextBuilder
6
+ def initialize(status, head: false, headers: {})
7
+ @status = status
8
+ @head = head
9
+ @extra_headers = headers
10
+ end
11
+
12
+ def build
13
+ Response.new(status: @status, headers: response_headers, body: response_body)
14
+ end
15
+
16
+ private
17
+
18
+ def response_body
19
+ @head ? "" : message
20
+ end
21
+
22
+ def message
23
+ @message ||= "#{@status} #{Status.reason(@status)}\n"
24
+ end
25
+
26
+ def response_headers
27
+ {
28
+ "Content-Type" => "text/plain; charset=utf-8",
29
+ "Content-Length" => message.bytesize.to_s,
30
+ "Cache-Control" => "no-cache"
31
+ }.merge(@extra_headers)
32
+ end
33
+ end
34
+ end
35
+ end
data/lib/wsv/response.rb CHANGED
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "time"
4
- require_relative "mime_types"
5
3
  require_relative "status"
6
4
  require_relative "version"
5
+ require_relative "response/string_body"
6
+ require_relative "response/file_body"
7
+ require_relative "response/text_builder"
8
+ require_relative "response/file_builder"
7
9
 
8
10
  module Wsv
9
11
  class Response
@@ -12,16 +14,17 @@ module Wsv
12
14
  INVALID_HEADER_NAME = /[\s:]/
13
15
  INVALID_HEADER_VALUE = /[\r\n]/
14
16
 
15
- attr_reader :status, :headers, :body
17
+ attr_reader :status, :headers
16
18
 
17
19
  def initialize(status:, headers: {}, body: "")
18
- headers.each do |name, value|
19
- raise ArgumentError, "invalid header name: #{name.inspect}" if name.to_s.match?(INVALID_HEADER_NAME)
20
- raise ArgumentError, "invalid header value: #{value.inspect}" if value.to_s.match?(INVALID_HEADER_VALUE)
21
- end
20
+ validate_headers(headers)
22
21
  @status = status
23
22
  @headers = headers
24
- @body = body
23
+ @body = body.is_a?(String) ? StringBody.new(body) : body
24
+ end
25
+
26
+ def body
27
+ @body.to_s
25
28
  end
26
29
 
27
30
  def reason
@@ -34,34 +37,36 @@ module Wsv
34
37
  io.write "Connection: close\r\n"
35
38
  headers.each { |name, value| io.write "#{name}: #{value}\r\n" }
36
39
  io.write "\r\n"
37
- io.write body
40
+ @body.write_to(io)
38
41
  end
39
42
 
40
- def self.text(status, headers: {}, head: false)
41
- body = "#{status} #{Status.reason(status)}\n"
42
- base = {
43
- "Content-Type" => "text/plain; charset=utf-8",
44
- "Content-Length" => body.bytesize.to_s,
45
- "Cache-Control" => "no-cache"
46
- }
47
- new(status: status, headers: base.merge(headers), body: head ? "" : body)
43
+ def self.text(status, **)
44
+ TextBuilder.new(status, **).build
48
45
  end
49
46
 
50
- def self.file(path, head: false)
51
- new(
52
- status: 200,
53
- headers: {
54
- "Content-Type" => MimeTypes.for_file(path),
55
- "Content-Length" => File.size(path).to_s,
56
- "Last-Modified" => File.mtime(path).httpdate,
57
- "Cache-Control" => "no-cache"
58
- },
59
- body: head ? "" : File.binread(path)
60
- )
47
+ def self.file(path, **)
48
+ FileBuilder.new(path, **).build
61
49
  end
62
50
 
63
51
  def self.redirect(location, head: false)
64
- text(301, headers: { "Location" => location }, head: head)
52
+ TextBuilder.new(301, head: head, headers: { "Location" => location }).build
53
+ end
54
+
55
+ def self.not_modified
56
+ new(status: 304, headers: { "Cache-Control" => "no-cache" }, body: "")
57
+ end
58
+
59
+ def self.range_not_satisfiable(file_size, head: false)
60
+ TextBuilder.new(416, head: head, headers: { "Content-Range" => "bytes */#{file_size}" }).build
61
+ end
62
+
63
+ private
64
+
65
+ def validate_headers(headers)
66
+ headers.each do |name, value|
67
+ raise ArgumentError, "invalid header name: #{name.inspect}" if name.to_s.match?(INVALID_HEADER_NAME)
68
+ raise ArgumentError, "invalid header value: #{value.inspect}" if value.to_s.match?(INVALID_HEADER_VALUE)
69
+ end
65
70
  end
66
71
  end
67
72
  end
data/lib/wsv/server.rb CHANGED
@@ -164,11 +164,17 @@ module Wsv
164
164
 
165
165
  return reject(client) unless accepted
166
166
 
167
- Thread.new do
168
- Thread.current.report_on_exception = false
169
- handle(client)
170
- ensure
167
+ begin
168
+ Thread.new do
169
+ Thread.current.report_on_exception = false
170
+ handle(client)
171
+ ensure
172
+ @mutex.synchronize { @active -= 1 }
173
+ end
174
+ rescue ThreadError => e
175
+ @err.puts "wsv: thread error: #{e.message}"
171
176
  @mutex.synchronize { @active -= 1 }
177
+ reject(client)
172
178
  end
173
179
  end
174
180
 
data/lib/wsv/status.rb CHANGED
@@ -4,13 +4,16 @@ module Wsv
4
4
  module Status
5
5
  REASONS = {
6
6
  200 => "OK",
7
+ 206 => "Partial Content",
7
8
  301 => "Moved Permanently",
9
+ 304 => "Not Modified",
8
10
  400 => "Bad Request",
9
11
  403 => "Forbidden",
10
12
  404 => "Not Found",
11
13
  405 => "Method Not Allowed",
12
14
  408 => "Request Timeout",
13
15
  414 => "URI Too Long",
16
+ 416 => "Range Not Satisfiable",
14
17
  431 => "Request Header Fields Too Large",
15
18
  503 => "Service Unavailable"
16
19
  }.freeze
data/lib/wsv/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wsv
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
data/test/app_test.rb CHANGED
@@ -60,6 +60,16 @@ class AppTest < Minitest::Test
60
60
  assert_equal "/docs/?q=1", response.headers["Location"]
61
61
  end
62
62
 
63
+ def test_redirect_normalizes_absolute_form_target_to_origin_form
64
+ FileUtils.mkdir_p(File.join(@dir, "docs"))
65
+ File.write(File.join(@dir, "docs", "index.html"), "x")
66
+
67
+ response = @app.call(req("GET", "http://example.test/docs"))
68
+
69
+ assert_equal 301, response.status
70
+ assert_equal "/docs/", response.headers["Location"]
71
+ end
72
+
63
73
  def test_head_omits_body_but_keeps_content_length
64
74
  File.write(File.join(@dir, "x.txt"), "hi")
65
75
 
@@ -69,9 +79,159 @@ class AppTest < Minitest::Test
69
79
  assert_equal "2", response.headers["Content-Length"]
70
80
  end
71
81
 
82
+ def test_advertises_accept_ranges_on_200
83
+ File.write(File.join(@dir, "x.txt"), "hi")
84
+
85
+ response = @app.call(req("GET", "/x.txt"))
86
+
87
+ assert_equal "bytes", response.headers["Accept-Ranges"]
88
+ end
89
+
90
+ def test_returns_304_when_if_modified_since_matches
91
+ path = File.join(@dir, "x.txt")
92
+ File.write(path, "hi")
93
+
94
+ response = @app.call(req("GET", "/x.txt", "if-modified-since" => File.mtime(path).httpdate))
95
+
96
+ assert_equal 304, response.status
97
+ assert_equal "", response.body
98
+ refute response.headers.key?("Content-Length")
99
+ end
100
+
101
+ def test_returns_200_when_if_modified_since_is_older
102
+ path = File.join(@dir, "x.txt")
103
+ File.write(path, "hi")
104
+ older = (File.mtime(path) - 3600).httpdate
105
+
106
+ response = @app.call(req("GET", "/x.txt", "if-modified-since" => older))
107
+
108
+ assert_equal 200, response.status
109
+ assert_equal "hi", response.body
110
+ end
111
+
112
+ def test_invalid_if_modified_since_is_ignored
113
+ File.write(File.join(@dir, "x.txt"), "hi")
114
+
115
+ response = @app.call(req("GET", "/x.txt", "if-modified-since" => "not a date"))
116
+
117
+ assert_equal 200, response.status
118
+ end
119
+
120
+ def test_serves_byte_range
121
+ File.write(File.join(@dir, "data.bin"), "abcdefghij")
122
+
123
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=2-5"))
124
+
125
+ assert_equal 206, response.status
126
+ assert_equal "cdef", response.body
127
+ assert_equal "4", response.headers["Content-Length"]
128
+ assert_equal "bytes 2-5/10", response.headers["Content-Range"]
129
+ end
130
+
131
+ def test_serves_open_ended_range
132
+ File.write(File.join(@dir, "data.bin"), "abcdefghij")
133
+
134
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=7-"))
135
+
136
+ assert_equal 206, response.status
137
+ assert_equal "hij", response.body
138
+ assert_equal "bytes 7-9/10", response.headers["Content-Range"]
139
+ end
140
+
141
+ def test_serves_suffix_range
142
+ File.write(File.join(@dir, "data.bin"), "abcdefghij")
143
+
144
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=-3"))
145
+
146
+ assert_equal 206, response.status
147
+ assert_equal "hij", response.body
148
+ assert_equal "bytes 7-9/10", response.headers["Content-Range"]
149
+ end
150
+
151
+ def test_clamps_range_end_to_file_size
152
+ File.write(File.join(@dir, "data.bin"), "abcdefghij")
153
+
154
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=5-99"))
155
+
156
+ assert_equal 206, response.status
157
+ assert_equal "fghij", response.body
158
+ assert_equal "bytes 5-9/10", response.headers["Content-Range"]
159
+ end
160
+
161
+ def test_unsatisfiable_range_returns_416
162
+ File.write(File.join(@dir, "data.bin"), "abc")
163
+
164
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=10-20"))
165
+
166
+ assert_equal 416, response.status
167
+ assert_equal "bytes */3", response.headers["Content-Range"]
168
+ end
169
+
170
+ def test_invalid_range_syntax_serves_full_content
171
+ File.write(File.join(@dir, "data.bin"), "abc")
172
+
173
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=garbage"))
174
+
175
+ assert_equal 200, response.status
176
+ assert_equal "abc", response.body
177
+ end
178
+
179
+ def test_head_with_range_omits_body_but_keeps_headers
180
+ File.write(File.join(@dir, "data.bin"), "abcdefghij")
181
+
182
+ response = @app.call(req("HEAD", "/data.bin", "range" => "bytes=0-2"))
183
+
184
+ assert_equal 206, response.status
185
+ assert_equal "", response.body
186
+ assert_equal "3", response.headers["Content-Length"]
187
+ end
188
+
189
+ def test_serves_single_byte_range
190
+ File.write(File.join(@dir, "data.bin"), "abcdefghij")
191
+
192
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=0-0"))
193
+
194
+ assert_equal 206, response.status
195
+ assert_equal "a", response.body
196
+ assert_equal "1", response.headers["Content-Length"]
197
+ assert_equal "bytes 0-0/10", response.headers["Content-Range"]
198
+ end
199
+
200
+ def test_inverted_range_returns_416
201
+ File.write(File.join(@dir, "data.bin"), "abcdefghij")
202
+
203
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=5-3"))
204
+
205
+ assert_equal 416, response.status
206
+ assert_equal "bytes */10", response.headers["Content-Range"]
207
+ end
208
+
209
+ def test_206_preserves_caching_headers
210
+ path = File.join(@dir, "data.bin")
211
+ File.write(path, "abcdefghij")
212
+
213
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=2-5"))
214
+
215
+ assert_equal 206, response.status
216
+ assert_equal Wsv::MimeTypes.for_file("data.bin"), response.headers["Content-Type"]
217
+ assert_equal File.mtime(path).httpdate, response.headers["Last-Modified"]
218
+ assert_equal "no-cache", response.headers["Cache-Control"]
219
+ assert_equal "bytes", response.headers["Accept-Ranges"]
220
+ end
221
+
222
+ def test_multipart_range_falls_through_to_200
223
+ File.write(File.join(@dir, "data.bin"), "abcdefghij")
224
+
225
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=0-2,5-7"))
226
+
227
+ assert_equal 200, response.status
228
+ assert_equal "abcdefghij", response.body
229
+ refute response.headers.key?("Content-Range")
230
+ end
231
+
72
232
  private
73
233
 
74
- def req(method, target)
75
- Wsv::Request.new(method: method, target: target, version: "HTTP/1.1", headers: {})
234
+ def req(method, target, headers = {})
235
+ Wsv::Request.new(method: method, target: target, version: "HTTP/1.1", headers: headers)
76
236
  end
77
237
  end
@@ -127,6 +127,41 @@ class PathResolverTest < Minitest::Test
127
127
  assert_equal 400, result.status
128
128
  end
129
129
 
130
+ def test_returns_400_for_nul_byte_in_path
131
+ result = @resolver.resolve("/foo%00bar")
132
+
133
+ assert_predicate result, :error?
134
+ assert_equal 400, result.status
135
+ end
136
+
137
+ def test_returns_400_for_cr_in_path
138
+ result = @resolver.resolve("/foo%0Dbar")
139
+
140
+ assert_predicate result, :error?
141
+ assert_equal 400, result.status
142
+ end
143
+
144
+ def test_returns_400_for_lf_in_path
145
+ result = @resolver.resolve("/foo%0Abar")
146
+
147
+ assert_predicate result, :error?
148
+ assert_equal 400, result.status
149
+ end
150
+
151
+ def test_returns_400_for_tab_in_path
152
+ result = @resolver.resolve("/foo%09bar")
153
+
154
+ assert_predicate result, :error?
155
+ assert_equal 400, result.status
156
+ end
157
+
158
+ def test_returns_400_for_del_in_path
159
+ result = @resolver.resolve("/foo%7Fbar")
160
+
161
+ assert_predicate result, :error?
162
+ assert_equal 400, result.status
163
+ end
164
+
130
165
  def test_rejects_symlink_to_dotfile
131
166
  File.write(File.join(@dir, ".env"), "secret")
132
167
  File.symlink(".env", File.join(@dir, "config"))
@@ -45,4 +45,45 @@ class ResponseTest < Minitest::Test
45
45
 
46
46
  assert_includes io.string, "HTTP/1.1 404 Not Found"
47
47
  end
48
+
49
+ def test_file_response_streams_via_io_copy_stream
50
+ Dir.mktmpdir do |dir|
51
+ path = File.join(dir, "data.bin")
52
+ File.binwrite(path, "abcdefghij")
53
+
54
+ response = Wsv::Response.file(path)
55
+ io = StringIO.new
56
+ response.write_to(io)
57
+
58
+ assert_includes io.string, "HTTP/1.1 200 OK"
59
+ assert io.string.end_with?("abcdefghij"), "expected file bytes at end of response"
60
+ end
61
+ end
62
+
63
+ def test_file_response_does_not_eagerly_read_file
64
+ Dir.mktmpdir do |dir|
65
+ path = File.join(dir, "data.bin")
66
+ File.binwrite(path, "abcdefghij")
67
+
68
+ response = Wsv::Response.file(path)
69
+ File.delete(path)
70
+
71
+ assert_equal 200, response.status
72
+ assert_equal "10", response.headers["Content-Length"]
73
+ end
74
+ end
75
+
76
+ def test_file_response_streams_byte_range
77
+ Dir.mktmpdir do |dir|
78
+ path = File.join(dir, "data.bin")
79
+ File.binwrite(path, "abcdefghij")
80
+
81
+ response = Wsv::Response.file(path, range: 2..5)
82
+ io = StringIO.new
83
+ response.write_to(io)
84
+
85
+ assert_includes io.string, "HTTP/1.1 206 Partial Content"
86
+ assert io.string.end_with?("cdef"), "expected only the requested range"
87
+ end
88
+ end
48
89
  end
data/test/server_test.rb CHANGED
@@ -226,6 +226,33 @@ class ServerTest < Minitest::Test
226
226
  socket&.close
227
227
  end
228
228
 
229
+ def test_serves_byte_range_e2e
230
+ File.write(File.join(@dir, "data.bin"), "abcdefghij")
231
+ start_server
232
+
233
+ uri = URI("http://127.0.0.1:#{@server.port}/data.bin")
234
+ response = Net::HTTP.start(uri.host, uri.port) do |http|
235
+ http.get(uri.path, "Range" => "bytes=2-5")
236
+ end
237
+
238
+ assert_equal "206", response.code
239
+ assert_equal "cdef", response.body
240
+ assert_equal "bytes 2-5/10", response["content-range"]
241
+ end
242
+
243
+ def test_returns_304_when_if_modified_since_matches_e2e
244
+ path = File.join(@dir, "x.txt")
245
+ File.write(path, "hi")
246
+ start_server
247
+
248
+ uri = URI("http://127.0.0.1:#{@server.port}/x.txt")
249
+ response = Net::HTTP.start(uri.host, uri.port) do |http|
250
+ http.get(uri.path, "If-Modified-Since" => File.mtime(path).httpdate)
251
+ end
252
+
253
+ assert_equal "304", response.code
254
+ end
255
+
229
256
  def test_unsupported_method
230
257
  start_server
231
258
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wsv
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - takahashim
@@ -27,7 +27,12 @@ files:
27
27
  - lib/wsv/mime_types.rb
28
28
  - lib/wsv/path_resolver.rb
29
29
  - lib/wsv/request.rb
30
+ - lib/wsv/request/parser.rb
30
31
  - lib/wsv/response.rb
32
+ - lib/wsv/response/file_body.rb
33
+ - lib/wsv/response/file_builder.rb
34
+ - lib/wsv/response/string_body.rb
35
+ - lib/wsv/response/text_builder.rb
31
36
  - lib/wsv/server.rb
32
37
  - lib/wsv/status.rb
33
38
  - lib/wsv/version.rb