raptor 0.6.0 → 0.8.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.
@@ -8,6 +8,7 @@ require "tempfile"
8
8
  require "atomic-ruby/atomic_boolean"
9
9
  require "rack"
10
10
 
11
+ require_relative "http"
11
12
  require_relative "raptor_http"
12
13
 
13
14
  module Raptor
@@ -16,11 +17,11 @@ module Raptor
16
17
  # with the reactor for requests that need more data before they
17
18
  # can be handled.
18
19
  #
19
- class Request
20
+ class Http1
20
21
  BODY_BUFFER_THRESHOLD = 256 * 1024
21
22
  FILE_CHUNK_SIZE = 64 * 1024
23
+ MAX_CHUNK_OVERHEAD = 16 * 1024
22
24
  READ_BUFFER_SIZE = 64 * 1024
23
- WRITE_TIMEOUT = 5
24
25
  KEEPALIVE_READ_TIMEOUT = 0.001
25
26
  MAX_KEEPALIVE_REQUESTS = 100
26
27
 
@@ -35,16 +36,19 @@ module Raptor
35
36
  h[status] = "HTTP/1.1 #{status}#{reason ? " #{reason}" : ""}\r\n".freeze
36
37
  end
37
38
 
38
- STATUS_WITH_NO_ENTITY_BODY = Set.new([204, 304, *100..199]).freeze
39
+ STATUS_WITH_NO_ENTITY_BODY = [204, 304, *100..199].freeze
40
+ CONTINUE_RESPONSE = "HTTP/1.1 100 Continue\r\n\r\n"
39
41
  BAD_REQUEST_RESPONSE = "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
40
- INTERNAL_SERVER_ERROR_RESPONSE = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
41
42
  CONTENT_TOO_LARGE_RESPONSE = "HTTP/1.1 413 Content Too Large\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
43
+ INTERNAL_SERVER_ERROR_RESPONSE = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
42
44
 
43
45
  CONNECTION_CLOSE = "close"
44
46
  CONNECTION_KEEPALIVE = "keep-alive"
47
+ EXPECT_100_CONTINUE = "100-continue"
45
48
  TRANSFER_ENCODING_CHUNKED = "chunked"
46
49
 
47
50
  HTTP_CONNECTION = "HTTP_CONNECTION"
51
+ HTTP_EXPECT = "HTTP_EXPECT"
48
52
  HTTP_TRANSFER_ENCODING = "HTTP_TRANSFER_ENCODING"
49
53
  RACK_HEADER_PREFIX = "rack."
50
54
  RACK_HIJACKED = "rack.hijacked"
@@ -53,17 +57,39 @@ module Raptor
53
57
  ILLEGAL_HEADER_KEY_REGEX = /[\x00-\x20\(\)<>@,;:\\"\/\[\]\?=\{\}\x7F]/
54
58
  ILLEGAL_HEADER_VALUE_REGEX = /[\x00-\x08\x0A-\x1F]/
55
59
 
56
- class Error < StandardError; end
57
- class WriteError < Error
58
- # @rbs () -> String
59
- def message = "could not write response"
60
+ # Returns true when the message framing shows a request-smuggling vector
61
+ # per RFC 9112 section 6.3: a `Transfer-Encoding` where `chunked` is
62
+ # missing, not the final encoding, or duplicated; a `Transfer-Encoding`
63
+ # paired with a `Content-Length`; or a `Content-Length` containing any
64
+ # non-digit character.
65
+ #
66
+ # @param env [Hash] the Rack environment after header parsing
67
+ # @return [Boolean]
68
+ #
69
+ # @rbs (Hash[String, untyped] env) -> bool
70
+ def self.request_smuggling?(env)
71
+ transfer_encoding = env[HTTP_TRANSFER_ENCODING]
72
+ content_length = env[Http::CONTENT_LENGTH]
73
+
74
+ if transfer_encoding
75
+ return true if content_length
76
+
77
+ encodings = transfer_encoding.downcase.split(",").map(&:strip)
78
+ return true if encodings.last != TRANSFER_ENCODING_CHUNKED
79
+ return true if encodings.count(TRANSFER_ENCODING_CHUNKED) > 1
80
+ elsif content_length
81
+ return true if content_length.match?(/[^\d]/)
82
+ end
83
+
84
+ false
60
85
  end
61
86
 
62
87
  # Decodes a chunked transfer-encoded body buffer.
63
88
  #
64
89
  # Returns the decoded bytes and a state symbol: `:complete` when the
65
90
  # terminating zero-length chunk was found, `:too_large` when the decoded
66
- # size would exceed `max_size`, or `:incomplete` otherwise.
91
+ # size would exceed `max_size`, `:malformed` when chunk framing overhead
92
+ # exceeds `MAX_CHUNK_OVERHEAD`, or `:incomplete` otherwise.
67
93
  #
68
94
  # @param buffer [String] the raw body buffer to decode
69
95
  # @param max_size [Integer, nil] maximum decoded body size, or nil for unlimited
@@ -73,6 +99,7 @@ module Raptor
73
99
  def self.decode_chunked(buffer, max_size = nil)
74
100
  decoded = String.new
75
101
  offset = 0
102
+ overhead = 0
76
103
 
77
104
  while offset < buffer.bytesize
78
105
  crlf = buffer.index("\r\n", offset)
@@ -80,7 +107,10 @@ module Raptor
80
107
 
81
108
  chunk_size = buffer.byteslice(offset, crlf - offset).to_i(16)
82
109
  return [decoded, :complete] if chunk_size == 0
83
- return [decoded, :too_large] if max_size && decoded.bytesize + chunk_size > max_size
110
+ return [decoded, :too_large] if max_size && (decoded.bytesize + chunk_size) > max_size
111
+
112
+ overhead += (crlf - offset) + 4
113
+ return [decoded, :malformed] if overhead > (decoded.bytesize + chunk_size + MAX_CHUNK_OVERHEAD)
84
114
 
85
115
  offset = crlf + 2
86
116
  decoded << buffer.byteslice(offset, chunk_size)
@@ -90,58 +120,56 @@ module Raptor
90
120
  [decoded, :incomplete]
91
121
  end
92
122
 
93
- # Writes `string` in full, retrying on partial writes. Bounded by
94
- # `WRITE_TIMEOUT` so a slow client can't pin the writing thread.
95
- #
96
- # @param socket [TCPSocket] the socket to write to
97
- # @param string [String] the data to write
98
- # @return [void]
99
- # @raise [WriteError] if the socket is not writable within the timeout or raises IOError
100
- #
101
- # @rbs (TCPSocket socket, String string) -> void
102
- def self.socket_write(socket, string)
103
- bytes = 0
104
- byte_size = string.bytesize
105
-
106
- while bytes < byte_size
107
- begin
108
- bytes += socket.write_nonblock(bytes.zero? ? string : string.byteslice(bytes..-1))
109
- rescue IO::WaitWritable
110
- raise WriteError unless socket.wait_writable(WRITE_TIMEOUT)
111
- retry
112
- rescue IOError
113
- raise WriteError
114
- end
115
- end
116
- end
117
-
118
123
  # @rbs @app: ^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped]
119
124
  # @rbs @server_port: Integer
125
+ # @rbs @write_timeout: Integer
120
126
  # @rbs @max_body_size: Integer?
121
127
  # @rbs @body_spool_threshold: Integer?
128
+ # @rbs @max_keepalive_requests: Integer
129
+ # @rbs @access_log_io: IO?
122
130
  # @rbs @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
123
131
  # @rbs @running: AtomicBoolean
124
132
 
125
- # Creates a new Request handler.
133
+ # Creates a new Http1 handler.
126
134
  #
127
135
  # @param app [#call] the Rack application to dispatch complete requests to
128
136
  # @param server_port [Integer] port number used to populate SERVER_PORT in the Rack env
129
- # @param client_options [Hash] client limits configuration
130
- # @option client_options [Integer, nil] :max_body_size maximum request body size in bytes
131
- # @option client_options [Integer, nil] :body_spool_threshold spool bodies larger than this to a tempfile
137
+ # @param connection_options [Hash] per-connection settings shared across protocols
138
+ # @option connection_options [Integer] :write_timeout per-write socket timeout in seconds
139
+ # @option connection_options [Integer, nil] :max_body_size maximum request body size in bytes
140
+ # @option connection_options [Integer, nil] :body_spool_threshold spool bodies larger than this to a tempfile
141
+ # @param http1_options [Hash] HTTP/1.1-specific settings
142
+ # @option http1_options [Integer] :max_keepalive_requests maximum requests per HTTP/1.1 keep-alive connection
143
+ # @param access_log_io [IO, nil] IO to write Common Log Format access entries to, or nil to disable
132
144
  # @param on_error [#call, nil] callback invoked with (env, exception) when the Rack app raises
133
145
  # @return [void]
134
146
  #
135
- # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port, ?client_options: Hash[Symbol, untyped], ?on_error: ^(Hash[String, untyped]?, Exception) -> void | nil) -> void
136
- def initialize(app, server_port, client_options: {}, on_error: nil)
147
+ # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port, ?connection_options: Hash[Symbol, untyped], ?http1_options: Hash[Symbol, untyped], ?access_log_io: IO?, ?on_error: ^(Hash[String, untyped]?, Exception) -> void | nil) -> void
148
+ def initialize(app, server_port, connection_options: {}, http1_options: {}, access_log_io: nil, on_error: nil)
137
149
  @app = app
138
150
  @server_port = server_port
139
- @max_body_size = client_options[:max_body_size]
140
- @body_spool_threshold = client_options[:body_spool_threshold]
151
+ @write_timeout = connection_options[:write_timeout] || Http::WRITE_TIMEOUT
152
+ @max_body_size = connection_options[:max_body_size]
153
+ @body_spool_threshold = connection_options[:body_spool_threshold]
154
+ @max_keepalive_requests = http1_options[:max_keepalive_requests] || MAX_KEEPALIVE_REQUESTS
155
+ @access_log_io = access_log_io
141
156
  @on_error = on_error
142
157
  @running = AtomicBoolean.new(true)
143
158
  end
144
159
 
160
+ # Instance-level wrapper around {Http.socket_write} that applies the
161
+ # configured `write_timeout`.
162
+ #
163
+ # @param socket [TCPSocket] the socket to write to
164
+ # @param string [String] the data to write
165
+ # @return [void]
166
+ # @raise [Http::WriteError] if the socket is not writable within the timeout or raises IOError
167
+ #
168
+ # @rbs (TCPSocket socket, String string) -> void
169
+ def socket_write(socket, string)
170
+ Http.socket_write(socket, string, timeout: @write_timeout)
171
+ end
172
+
145
173
  # Signals eager keep-alive loops to stop processing further requests on
146
174
  # their connections. In-flight requests complete normally.
147
175
  #
@@ -202,6 +230,9 @@ module Raptor
202
230
  if !parser.finished?
203
231
  fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, 0, remote_addr, url_scheme, persisted: false)
204
232
  return
233
+ elsif Http1.request_smuggling?(env)
234
+ reject_malformed(socket)
235
+ return
205
236
  elsif parser.has_body?
206
237
  if @max_body_size && parser.content_length > @max_body_size
207
238
  reject_oversized(socket)
@@ -211,13 +242,16 @@ module Raptor
211
242
  body = buffer.byteslice(nread..-1) || ""
212
243
 
213
244
  if env[HTTP_TRANSFER_ENCODING]&.include?(TRANSFER_ENCODING_CHUNKED)
214
- body, chunked_state = Request.decode_chunked(body, @max_body_size)
245
+ body, chunked_state = Http1.decode_chunked(body, @max_body_size)
215
246
  case chunked_state
216
247
  when :complete
217
248
  env.delete(HTTP_TRANSFER_ENCODING)
218
249
  when :too_large
219
250
  reject_oversized(socket)
220
251
  return
252
+ when :malformed
253
+ reject_malformed(socket)
254
+ return
221
255
  else
222
256
  fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, 0, remote_addr, url_scheme, persisted: false)
223
257
  return
@@ -263,13 +297,15 @@ module Raptor
263
297
  parse_data[:parse_count] += 1
264
298
 
265
299
  message = if parser.finished?
266
- if parser.has_body?
300
+ if Raptor::Http1.request_smuggling?(env)
301
+ data.merge(env: env, body: nil, parse_data: parse_data, complete: true, malformed: true)
302
+ elsif parser.has_body?
267
303
  body_buffer = data[:buffer].byteslice(nread..-1) || ""
268
304
 
269
305
  if max_body_size && parser.content_length > max_body_size
270
306
  data.merge(env: env, body: nil, parse_data: parse_data, complete: true, too_large: true)
271
307
  elsif env[HTTP_TRANSFER_ENCODING]&.include?(TRANSFER_ENCODING_CHUNKED)
272
- decoded_body, chunked_state = Raptor::Request.decode_chunked(body_buffer, max_body_size)
308
+ decoded_body, chunked_state = Raptor::Http1.decode_chunked(body_buffer, max_body_size)
273
309
 
274
310
  case chunked_state
275
311
  when :complete
@@ -277,6 +313,8 @@ module Raptor
277
313
  data.merge(env: env, body: decoded_body, parse_data: parse_data, complete: true)
278
314
  when :too_large
279
315
  data.merge(env: env, body: nil, parse_data: parse_data, complete: true, too_large: true)
316
+ when :malformed
317
+ data.merge(env: env, body: nil, parse_data: parse_data, complete: true, malformed: true)
280
318
  else
281
319
  data.merge(env: env, parse_data: parse_data)
282
320
  end
@@ -320,6 +358,7 @@ module Raptor
320
358
  end
321
359
 
322
360
  unless parsed_request[:complete]
361
+ parsed_request = send_continue_if_expected(parsed_request, reactor)
323
362
  reactor.update_state(parsed_request)
324
363
  else
325
364
  socket = reactor.remove(parsed_request[:id])
@@ -346,6 +385,41 @@ module Raptor
346
385
 
347
386
  private
348
387
 
388
+ # Returns true if the request expects a 100 Continue response per
389
+ # RFC 7231 section 5.1.1.
390
+ #
391
+ # @param env [Hash] the parsed Rack environment (possibly incomplete)
392
+ # @return [Boolean]
393
+ #
394
+ # @rbs (Hash[String, untyped] env) -> bool
395
+ def expects_100_continue?(env)
396
+ (env[Rack::SERVER_PROTOCOL] == HTTP_11) && env[HTTP_EXPECT]&.casecmp?(EXPECT_100_CONTINUE)
397
+ end
398
+
399
+ # Sends an HTTP 100 Continue response when an HTTP/1.1 client requested
400
+ # `Expect: 100-continue` and the request body has not yet been received.
401
+ #
402
+ # Returns the state hash with `:continued` set when the response has been
403
+ # written. A write failure is silently ignored.
404
+ #
405
+ # @param state [Hash] the partially-parsed connection state
406
+ # @param reactor [Reactor] the reactor holding the connection's socket
407
+ # @return [Hash] the state, with `:continued` set if 100 was written
408
+ #
409
+ # @rbs (Hash[Symbol, untyped] state, Reactor reactor) -> Hash[Symbol, untyped]
410
+ def send_continue_if_expected(state, reactor)
411
+ return state if state[:continued]
412
+
413
+ env = state[:env]
414
+ return state unless env && expects_100_continue?(env)
415
+
416
+ socket = reactor.socket_for(state[:id])
417
+ return state unless socket
418
+
419
+ socket_write(socket, CONTINUE_RESPONSE) rescue nil
420
+ state.merge(continued: true)
421
+ end
422
+
349
423
  # Processes a client connection by handling the current request and,
350
424
  # if keep-alive, eagerly reading subsequent requests inline.
351
425
  #
@@ -400,10 +474,12 @@ module Raptor
400
474
  hijacked = headers.is_a?(Hash) && !!headers[Rack::RACK_HIJACK]
401
475
  streaming = body.respond_to?(:call) && !body.respond_to?(:each)
402
476
  keep_alive = (hijacked || streaming) ? false : keep_alive?(rack_env, request_count)
477
+ response_size = response_size(headers, body) unless hijacked
403
478
  response_started = true
404
479
  write_response(socket, rack_env, status, headers, body, keep_alive: keep_alive)
405
480
  end
406
481
 
482
+ write_access_log(rack_env, status, response_size, remote_addr) if @access_log_io && !hijacked
407
483
  call_response_finished(rack_env, status, headers, nil)
408
484
  keep_alive && !hijacked
409
485
  rescue => error
@@ -549,6 +625,9 @@ module Raptor
549
625
  #
550
626
  # @rbs (TCPSocket socket, Integer id, String buffer, Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, Reactor reactor, Integer request_count, String remote_addr, String url_scheme, persisted: bool) -> void
551
627
  def fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, request_count, remote_addr, url_scheme, persisted: true)
628
+ continued = expects_100_continue?(env)
629
+ socket_write(socket, CONTINUE_RESPONSE) rescue nil if continued
630
+
552
631
  reactor.persist(socket, id, request_count, remote_addr: remote_addr, url_scheme: url_scheme)
553
632
  state = {
554
633
  id: id,
@@ -560,6 +639,7 @@ module Raptor
560
639
  url_scheme: url_scheme
561
640
  }
562
641
  state[:persisted] = true if persisted
642
+ state[:continued] = true if continued
563
643
  reactor.update_state(Ractor.make_shareable(state))
564
644
  end
565
645
 
@@ -603,7 +683,6 @@ module Raptor
603
683
  # @rbs (Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, String? body, TCPSocket socket, ?remote_addr: String, ?url_scheme: String) -> Hash[String, untyped]
604
684
  def build_rack_env(env, parse_data, body, socket, remote_addr: Server::DEFAULT_REMOTE_ADDR, url_scheme: Server::HTTP_SCHEME)
605
685
  env[Rack::RACK_VERSION] = Rack::VERSION
606
- env[Rack::RACK_URL_SCHEME] = url_scheme
607
686
  env[Rack::RACK_INPUT] = build_rack_input(body)
608
687
  env[Rack::RACK_ERRORS] = $stderr
609
688
  env[Rack::RACK_RESPONSE_FINISHED] = []
@@ -625,10 +704,16 @@ module Raptor
625
704
  env[Rack::QUERY_STRING] = "" unless env.key?(Rack::QUERY_STRING)
626
705
 
627
706
  if (content_length = parse_data[:content_length]).positive?
628
- env["CONTENT_LENGTH"] = content_length.to_s
707
+ env[Http::CONTENT_LENGTH] = content_length.to_s
629
708
  end
630
709
 
631
- env["REMOTE_ADDR"] = remote_addr
710
+ env[Http::REMOTE_ADDR] = remote_addr
711
+ env[Http::SERVER_SOFTWARE] = Http::SERVER_SOFTWARE_VALUE
712
+ env[Http::HTTP_VERSION] = env[Rack::SERVER_PROTOCOL]
713
+
714
+ behind_tls_proxy = (url_scheme == Server::HTTP_SCHEME) && forwarded_https?(env)
715
+ env[Rack::RACK_URL_SCHEME] = behind_tls_proxy ? Server::HTTPS_SCHEME : url_scheme
716
+ default_port = behind_tls_proxy ? "443" : @server_port.to_s
632
717
 
633
718
  http_host = env[Rack::HTTP_HOST]
634
719
  if http_host
@@ -639,10 +724,10 @@ module Raptor
639
724
  host, port = http_host.split(":", 2)
640
725
  end
641
726
  env[Rack::SERVER_NAME] ||= host
642
- env[Rack::SERVER_PORT] ||= port || @server_port.to_s
727
+ env[Rack::SERVER_PORT] ||= port || default_port
643
728
  else
644
729
  env[Rack::SERVER_NAME] ||= Server::DEFAULT_SERVER_NAME
645
- env[Rack::SERVER_PORT] ||= @server_port.to_s
730
+ env[Rack::SERVER_PORT] ||= default_port
646
731
  end
647
732
 
648
733
  env
@@ -668,6 +753,21 @@ module Raptor
668
753
  end
669
754
  end
670
755
 
756
+ # Returns true when an upstream proxy signals that it terminated TLS for
757
+ # this request via `X-Forwarded-Proto`, `X-Forwarded-Scheme`, or
758
+ # `X-Forwarded-Ssl`. Only the first comma-separated value is consulted.
759
+ #
760
+ # @param env [Hash] the Rack environment
761
+ # @return [Boolean]
762
+ #
763
+ # @rbs (Hash[String, untyped] env) -> bool
764
+ def forwarded_https?(env)
765
+ proto = env["HTTP_X_FORWARDED_PROTO"] || env["HTTP_X_FORWARDED_SCHEME"]
766
+ return true if proto && proto.split(",").first&.strip&.casecmp?(Server::HTTPS_SCHEME)
767
+
768
+ env["HTTP_X_FORWARDED_SSL"]&.casecmp?("on") || false
769
+ end
770
+
671
771
  # Determines whether the connection should be kept alive after the response.
672
772
  #
673
773
  # Returns false if the request limit has been reached. For HTTP/1.1, keep-alive
@@ -680,7 +780,7 @@ module Raptor
680
780
  #
681
781
  # @rbs (Hash[String, untyped] env, Integer request_count) -> bool
682
782
  def keep_alive?(env, request_count)
683
- return false if request_count >= MAX_KEEPALIVE_REQUESTS
783
+ return false if request_count >= @max_keepalive_requests
684
784
 
685
785
  connection_header = env[HTTP_CONNECTION]
686
786
 
@@ -716,7 +816,7 @@ module Raptor
716
816
  end
717
817
  response << "\r\n"
718
818
 
719
- Request.socket_write(socket, response)
819
+ socket_write(socket, response)
720
820
  end
721
821
 
722
822
  # Writes a complete HTTP response to the socket.
@@ -849,7 +949,7 @@ module Raptor
849
949
  def write_hijacked_response(socket, response, headers, response_hijack)
850
950
  response << format_headers(headers)
851
951
  response << "\r\n"
852
- Request.socket_write(socket, response)
952
+ socket_write(socket, response)
853
953
  uncork_socket(socket)
854
954
  response_hijack.call(socket)
855
955
  end
@@ -874,7 +974,7 @@ module Raptor
874
974
 
875
975
  response << format_headers(headers)
876
976
  response << "\r\n"
877
- Request.socket_write(socket, response)
977
+ socket_write(socket, response)
878
978
  end
879
979
 
880
980
  # Writes a complete response with a body.
@@ -896,7 +996,7 @@ module Raptor
896
996
  if body.respond_to?(:call)
897
997
  response << format_headers(headers)
898
998
  response << "\r\n"
899
- Request.socket_write(socket, response)
999
+ socket_write(socket, response)
900
1000
  uncork_socket(socket)
901
1001
  body.call(socket)
902
1002
  return
@@ -933,7 +1033,7 @@ module Raptor
933
1033
  raise TypeError, "body must respond to each, to_ary, or to_path"
934
1034
  end
935
1035
 
936
- Request.socket_write(socket, "0\r\n\r\n") if use_chunked
1036
+ socket_write(socket, "0\r\n\r\n") if use_chunked
937
1037
  end
938
1038
 
939
1039
  # Calculates content length from an array or file body without consuming it.
@@ -947,7 +1047,7 @@ module Raptor
947
1047
  def calculate_content_length(body)
948
1048
  if body.respond_to?(:to_ary)
949
1049
  array = body.to_ary
950
- return nil unless array.is_a?(Array)
1050
+ return unless array.is_a?(Array)
951
1051
 
952
1052
  array.sum { |chunk| chunk.is_a?(String) ? chunk.bytesize : 0 }
953
1053
  elsif body.respond_to?(:to_path) && (path = body.to_path) && File.readable?(path)
@@ -973,15 +1073,15 @@ module Raptor
973
1073
  def write_file_body(socket, response, path, content_length, use_chunked)
974
1074
  File.open(path, "rb") do |file|
975
1075
  if use_chunked
976
- Request.socket_write(socket, response)
1076
+ socket_write(socket, response)
977
1077
  while (chunk = file.read(FILE_CHUNK_SIZE))
978
- Request.socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
1078
+ socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
979
1079
  end
980
1080
  elsif content_length && content_length < BODY_BUFFER_THRESHOLD
981
1081
  response << file.read(content_length)
982
- Request.socket_write(socket, response)
1082
+ socket_write(socket, response)
983
1083
  else
984
- Request.socket_write(socket, response)
1084
+ socket_write(socket, response)
985
1085
  IO.copy_stream(file, socket)
986
1086
  end
987
1087
  end
@@ -1024,12 +1124,12 @@ module Raptor
1024
1124
 
1025
1125
  if use_chunked
1026
1126
  response << "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n"
1027
- Request.socket_write(socket, response)
1127
+ socket_write(socket, response)
1028
1128
  elsif chunk.bytesize < BODY_BUFFER_THRESHOLD
1029
- Request.socket_write(socket, response << chunk)
1129
+ socket_write(socket, response << chunk)
1030
1130
  else
1031
- Request.socket_write(socket, response)
1032
- Request.socket_write(socket, chunk)
1131
+ socket_write(socket, response)
1132
+ socket_write(socket, chunk)
1033
1133
  end
1034
1134
  end
1035
1135
 
@@ -1045,13 +1145,13 @@ module Raptor
1045
1145
  # @rbs (TCPSocket socket, String response, Array[String] body_array, bool use_chunked) -> void
1046
1146
  def write_multiple_chunks(socket, response, body_array, use_chunked)
1047
1147
  if use_chunked
1048
- Request.socket_write(socket, response)
1148
+ socket_write(socket, response)
1049
1149
  body_array.each do |chunk|
1050
1150
  raise TypeError, "body must yield String values" unless chunk.is_a?(String)
1051
1151
 
1052
1152
  next if chunk.empty?
1053
1153
 
1054
- Request.socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
1154
+ socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
1055
1155
  end
1056
1156
  else
1057
1157
  body_array.each do |chunk|
@@ -1059,7 +1159,7 @@ module Raptor
1059
1159
 
1060
1160
  response << chunk
1061
1161
  end
1062
- Request.socket_write(socket, response)
1162
+ socket_write(socket, response)
1063
1163
  end
1064
1164
  end
1065
1165
 
@@ -1075,13 +1175,13 @@ module Raptor
1075
1175
  # @rbs (TCPSocket socket, String response, untyped body, bool use_chunked) -> void
1076
1176
  def write_enumerable_body(socket, response, body, use_chunked)
1077
1177
  if use_chunked
1078
- Request.socket_write(socket, response)
1178
+ socket_write(socket, response)
1079
1179
  body.each do |chunk|
1080
1180
  raise TypeError, "body must yield String values" unless chunk.is_a?(String)
1081
1181
 
1082
1182
  next if chunk.empty?
1083
1183
 
1084
- Request.socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
1184
+ socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
1085
1185
  end
1086
1186
  else
1087
1187
  body.each do |chunk|
@@ -1089,7 +1189,7 @@ module Raptor
1089
1189
 
1090
1190
  response << chunk
1091
1191
  end
1092
- Request.socket_write(socket, response)
1192
+ socket_write(socket, response)
1093
1193
  end
1094
1194
  end
1095
1195
 
@@ -1127,16 +1227,10 @@ module Raptor
1127
1227
  headers.each do |name, value|
1128
1228
  next if illegal_header_key?(name)
1129
1229
 
1130
- if value.is_a?(Array)
1131
- value.each do |header_value|
1132
- next if illegal_header_value?(header_value.to_s)
1133
-
1134
- result << "#{name}: #{header_value}\r\n"
1135
- end
1136
- else
1137
- next if illegal_header_value?(value.to_s)
1230
+ Array(value).flat_map { |entry| entry.to_s.split("\n") }.each do |header_value|
1231
+ next if illegal_header_value?(header_value)
1138
1232
 
1139
- result << "#{name}: #{value}\r\n"
1233
+ result << "#{name}: #{header_value}\r\n"
1140
1234
  end
1141
1235
  end
1142
1236
  result
@@ -1162,6 +1256,33 @@ module Raptor
1162
1256
  end
1163
1257
  end
1164
1258
 
1259
+ # Instance-level wrapper around {Http.write_access_log} that routes to
1260
+ # the configured `@access_log_io`.
1261
+ #
1262
+ # @param env [Hash] the Rack environment
1263
+ # @param status [Integer] the response status code
1264
+ # @param size [String] the response body size in bytes, or `-` if unknown
1265
+ # @param remote_addr [String] the client IP address
1266
+ # @return [void]
1267
+ #
1268
+ # @rbs (Hash[String, untyped] env, Integer status, String size, String remote_addr) -> void
1269
+ def write_access_log(env, status, size, remote_addr)
1270
+ Http.write_access_log(@access_log_io, env, status, size, remote_addr)
1271
+ end
1272
+
1273
+ # Returns the response body size as a String for the access log, taken
1274
+ # from the `content-length` header when set, computed from the body
1275
+ # otherwise, or `-` when the size cannot be determined upfront.
1276
+ #
1277
+ # @param headers [Hash] the response headers
1278
+ # @param body [Object] the response body
1279
+ # @return [String]
1280
+ #
1281
+ # @rbs (Hash[String, String | Array[String]] headers, untyped body) -> String
1282
+ def response_size(headers, body)
1283
+ headers[Rack::CONTENT_LENGTH] || calculate_content_length(body)&.to_s || "-"
1284
+ end
1285
+
1165
1286
  if Socket.const_defined?(:TCP_CORK)
1166
1287
  # Enables TCP_CORK on the socket to batch outgoing packets into fewer segments.
1167
1288
  #