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 +4 -4
- data/CHANGELOG.md +34 -1
- data/README.md +31 -2
- data/lib/wsv/app.rb +70 -2
- data/lib/wsv/path_resolver.rb +8 -1
- data/lib/wsv/request/parser.rb +52 -0
- data/lib/wsv/request.rb +3 -38
- data/lib/wsv/response/file_body.rb +31 -0
- data/lib/wsv/response/file_builder.rb +62 -0
- data/lib/wsv/response/string_body.rb +23 -0
- data/lib/wsv/response/text_builder.rb +35 -0
- data/lib/wsv/response.rb +34 -29
- data/lib/wsv/server.rb +10 -4
- data/lib/wsv/status.rb +3 -0
- data/lib/wsv/version.rb +1 -1
- data/test/app_test.rb +162 -2
- data/test/path_resolver_test.rb +35 -0
- data/test/response_test.rb +41 -0
- data/test/server_test.rb +27 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3d932b12a288921b6935081095a656825c9efaaabb9d086350104c6d92c76d42
|
|
4
|
+
data.tar.gz: 91c097132033a2030f4e5e9f8f4aa9c799078e7006320cbfff566ea88ae6c1b9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a272e137f58400e80dd58d4d32f6dd1f998ee5eb148c281d95674be1b087ea72c966ea4d3b84381b28589181d0e3e1a787accea3133e899f2dd8490c04ace055
|
|
7
|
+
data.tar.gz: 7250c73ce5e90e6c85d598b5966bdc01467bc5aeff6869c3e63e2031a8f7c7d2baa8d873f0e347bf76d55e7e567787ba041cd3c4b790a2d192b5ff8fc806d2de
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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`
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/wsv/path_resolver.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
17
|
+
attr_reader :status, :headers
|
|
16
18
|
|
|
17
19
|
def initialize(status:, headers: {}, body: "")
|
|
18
|
-
headers
|
|
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
|
|
40
|
+
@body.write_to(io)
|
|
38
41
|
end
|
|
39
42
|
|
|
40
|
-
def self.text(status,
|
|
41
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
Thread.
|
|
169
|
-
|
|
170
|
-
|
|
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
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
|
data/test/path_resolver_test.rb
CHANGED
|
@@ -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"))
|
data/test/response_test.rb
CHANGED
|
@@ -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.
|
|
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
|