tina4ruby 3.13.137 → 3.13.138

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.
Files changed (160) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/COMMERCIAL-LICENSE.md +8 -0
  4. data/LICENSE +373 -0
  5. data/LICENSE.txt +373 -21
  6. data/NOTICE +11 -0
  7. data/README.md +11 -7
  8. data/exe/tina4ruby +6 -0
  9. data/lib/tina4/ai.rb +6 -0
  10. data/lib/tina4/ai_client.rb +10 -3
  11. data/lib/tina4/api.rb +57 -7
  12. data/lib/tina4/auth.rb +18 -12
  13. data/lib/tina4/auto_crud.rb +83 -27
  14. data/lib/tina4/background.rb +7 -1
  15. data/lib/tina4/base64.rb +59 -0
  16. data/lib/tina4/cache.rb +6 -0
  17. data/lib/tina4/cache_backends/base_backend.rb +6 -0
  18. data/lib/tina4/cache_backends/database_backend.rb +6 -0
  19. data/lib/tina4/cache_backends/file_backend.rb +6 -0
  20. data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
  21. data/lib/tina4/cache_backends/memory_backend.rb +6 -0
  22. data/lib/tina4/cache_backends/mongo_backend.rb +6 -0
  23. data/lib/tina4/cache_backends/redis_backend.rb +6 -0
  24. data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
  25. data/lib/tina4/cache_backends.rb +6 -0
  26. data/lib/tina4/cli.rb +22 -7
  27. data/lib/tina4/constants.rb +12 -8
  28. data/lib/tina4/container.rb +6 -0
  29. data/lib/tina4/context/chunker.rb +6 -0
  30. data/lib/tina4/context.rb +13 -5
  31. data/lib/tina4/cors.rb +66 -10
  32. data/lib/tina4/crud.rb +78 -9
  33. data/lib/tina4/database/sqlite3_adapter.rb +9 -1
  34. data/lib/tina4/database.rb +260 -44
  35. data/lib/tina4/database_adapter.rb +6 -0
  36. data/lib/tina4/database_result.rb +6 -0
  37. data/lib/tina4/database_url.rb +6 -0
  38. data/lib/tina4/debug.rb +6 -0
  39. data/lib/tina4/dev.rb +6 -0
  40. data/lib/tina4/dev_admin.rb +215 -89
  41. data/lib/tina4/dev_mailbox.rb +6 -1
  42. data/lib/tina4/dispatch_pipeline.rb +86 -1
  43. data/lib/tina4/docs.rb +6 -0
  44. data/lib/tina4/docstore.rb +23 -4
  45. data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
  46. data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
  47. data/lib/tina4/drivers/firebird_driver.rb +95 -6
  48. data/lib/tina4/drivers/mongodb_driver.rb +6 -0
  49. data/lib/tina4/drivers/mssql_driver.rb +54 -39
  50. data/lib/tina4/drivers/mysql_driver.rb +6 -0
  51. data/lib/tina4/drivers/odbc_driver.rb +6 -0
  52. data/lib/tina4/drivers/postgres_driver.rb +35 -8
  53. data/lib/tina4/drivers/schema_split.rb +6 -0
  54. data/lib/tina4/drivers/sqlite_driver.rb +8 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +6 -0
  57. data/lib/tina4/error_overlay.rb +6 -0
  58. data/lib/tina4/events.rb +6 -0
  59. data/lib/tina4/feedback.rb +9 -2
  60. data/lib/tina4/field_types.rb +6 -0
  61. data/lib/tina4/form_parser.rb +241 -0
  62. data/lib/tina4/frond.rb +196 -56
  63. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
  64. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
  65. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
  66. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
  67. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
  68. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
  69. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
  70. data/lib/tina4/graph.rb +6 -0
  71. data/lib/tina4/graphql.rb +28 -27
  72. data/lib/tina4/health.rb +6 -0
  73. data/lib/tina4/html_element.rb +6 -0
  74. data/lib/tina4/http_server.rb +690 -0
  75. data/lib/tina4/imap_client.rb +352 -0
  76. data/lib/tina4/import_helper.rb +6 -0
  77. data/lib/tina4/job.rb +6 -0
  78. data/lib/tina4/localization.rb +6 -0
  79. data/lib/tina4/log.rb +6 -0
  80. data/lib/tina4/mail_socket.rb +124 -0
  81. data/lib/tina4/mcp.rb +15 -8
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +24 -9
  85. data/lib/tina4/migration.rb +6 -0
  86. data/lib/tina4/model_collection.rb +6 -0
  87. data/lib/tina4/mqtt.rb +9 -2
  88. data/lib/tina4/mqtt_message.rb +6 -0
  89. data/lib/tina4/orm.rb +72 -16
  90. data/lib/tina4/parse_json.rb +25 -0
  91. data/lib/tina4/plan.rb +9 -2
  92. data/lib/tina4/point.rb +6 -0
  93. data/lib/tina4/port_takeover.rb +6 -0
  94. data/lib/tina4/project_index.rb +6 -0
  95. data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
  96. data/lib/tina4/public/bundled-component-provenance.json +48 -0
  97. data/lib/tina4/public/js/tina4js.min.js +13 -3
  98. data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  99. data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  100. data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  101. data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
  102. data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
  103. data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  104. data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
  105. data/lib/tina4/push.rb +9 -3
  106. data/lib/tina4/query_builder.rb +6 -0
  107. data/lib/tina4/queue.rb +6 -0
  108. data/lib/tina4/queue_backends/kafka_backend.rb +10 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +6 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
  112. data/lib/tina4/rack_app.rb +11 -1
  113. data/lib/tina4/rate_limiter.rb +6 -0
  114. data/lib/tina4/realtime/attachment.rb +6 -0
  115. data/lib/tina4/realtime/channel.rb +6 -0
  116. data/lib/tina4/realtime/channel_member.rb +6 -0
  117. data/lib/tina4/realtime/local_storage.rb +6 -0
  118. data/lib/tina4/realtime/message.rb +6 -0
  119. data/lib/tina4/realtime/s3_storage.rb +6 -0
  120. data/lib/tina4/realtime/storage.rb +6 -0
  121. data/lib/tina4/realtime/storage_backend.rb +6 -0
  122. data/lib/tina4/realtime/workspace.rb +6 -0
  123. data/lib/tina4/realtime.rb +10 -3
  124. data/lib/tina4/request.rb +85 -127
  125. data/lib/tina4/response.rb +71 -9
  126. data/lib/tina4/response_cache.rb +6 -0
  127. data/lib/tina4/router.rb +6 -0
  128. data/lib/tina4/secret_file.rb +34 -0
  129. data/lib/tina4/seeder.rb +9 -3
  130. data/lib/tina4/service.rb +8 -2
  131. data/lib/tina4/service_runner.rb +6 -0
  132. data/lib/tina4/session.rb +6 -0
  133. data/lib/tina4/session_handlers/database_handler.rb +6 -0
  134. data/lib/tina4/session_handlers/file_handler.rb +6 -0
  135. data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
  136. data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
  137. data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
  138. data/lib/tina4/session_handlers/redis_handler.rb +6 -0
  139. data/lib/tina4/session_handlers/resp_client.rb +6 -0
  140. data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
  141. data/lib/tina4/shutdown.rb +17 -14
  142. data/lib/tina4/smtp_client.rb +222 -0
  143. data/lib/tina4/sql_translator.rb +93 -0
  144. data/lib/tina4/sqlite3_gem.rb +27 -0
  145. data/lib/tina4/sso.rb +25 -8
  146. data/lib/tina4/swagger.rb +6 -0
  147. data/lib/tina4/template.rb +6 -0
  148. data/lib/tina4/test.rb +8 -2
  149. data/lib/tina4/test_client.rb +8 -1
  150. data/lib/tina4/testing.rb +8 -1
  151. data/lib/tina4/validator.rb +6 -0
  152. data/lib/tina4/version.rb +7 -1
  153. data/lib/tina4/webserver.rb +25 -143
  154. data/lib/tina4/websocket.rb +10 -4
  155. data/lib/tina4/websocket_backplane.rb +19 -2
  156. data/lib/tina4/wsdl.rb +31 -16
  157. data/lib/tina4/xml_parser.rb +259 -0
  158. data/lib/tina4.rb +87 -31
  159. data/lib/tina4ruby.rb +6 -0
  160. metadata +43 -140
@@ -0,0 +1,690 @@
1
+ # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
8
+
9
+ require "socket"
10
+ require "io/wait"
11
+ require "stringio"
12
+ require "json"
13
+
14
+ module Tina4
15
+ # Tina4's own HTTP/1.1 server, on stdlib `socket` only.
16
+ #
17
+ # It replaced WEBrick (development) and Puma (production) as the default, the
18
+ # way Python's built-in asyncio server and PHP's Tina4/Server.php are their
19
+ # frameworks' defaults; Puma is still used when the APPLICATION bundles it
20
+ # (ADR-0067). The app is any Rack-style object answering call(env) with
21
+ # [status, headers, body], so Tina4::RackApp runs here unchanged and any Rack
22
+ # server can still run Tina4.
23
+ #
24
+ # The wire rules are ADR-0068's, the same in every built-in Tina4 server:
25
+ #
26
+ # TINA4_MAX_REQUEST_HEADER head bytes before 431 (65536)
27
+ # TINA4_MAX_UPLOAD_SIZE declared AND running body cap (10485760)
28
+ # TINA4_REQUEST_TIMEOUT silent seconds before 408 (30, 0 disables)
29
+ #
30
+ # A declared Content-Length over the cap is refused BEFORE any body byte is
31
+ # read, the body is read in bounded chunks under a running count (chunked
32
+ # included), malformed framing is 400, and every rejection has one shape:
33
+ # a JSON {"error": ...} body with the canonical security headers and
34
+ # Connection: close.
35
+ #
36
+ # tina4: one thread per connection, capped at MAX_CONNECTIONS (the accept
37
+ # loop waits for a free slot). Swap for a reactor + worker pool if idle
38
+ # keep-alive connections ever need to outnumber threads.
39
+ class HttpServer
40
+ DEFAULT_REQUEST_TIMEOUT = 30
41
+ DEFAULT_MAX_REQUEST_HEADER = 65_536
42
+ MAX_CONNECTIONS = 1024
43
+ READ_SIZE = 16_384
44
+ SOFTWARE = "tina4-server"
45
+
46
+ # RFC 9110 token: what a header field name (and a method) may contain.
47
+ TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
48
+ # A bare CR, LF or NUL anywhere in a request head is a smuggling attempt.
49
+ ILLEGAL_HEAD_BYTE = /[\r\n\0]/
50
+ # CR, LF or NUL in a RESPONSE header would split it into attacker-chosen
51
+ # headers or a second response (ADR-0068). Such a header is never written.
52
+ ILLEGAL_RESPONSE_VALUE = /[\r\n\0]/
53
+ MALFORMED_HEAD = "Malformed request head"
54
+ INVALID_LENGTH = "Invalid Content-Length"
55
+ INVALID_ENCODING = "Invalid Transfer-Encoding"
56
+ TIMED_OUT = "Request timed out before it was complete"
57
+ # After a rejection, what the client is still sending is read and thrown
58
+ # away for this long, so it sees the answer instead of a reset.
59
+ LINGER_SECONDS = 2
60
+ STATUSES_WITHOUT_BODY = [204, 304].freeze
61
+
62
+ Listener = Struct.new(:socket, :app, :port)
63
+
64
+ # An answer the server gives itself, before (or instead of) the app.
65
+ class RequestRefused < StandardError
66
+ attr_reader :status
67
+
68
+ def initialize(status, message)
69
+ super(message)
70
+ @status = status
71
+ end
72
+ end
73
+
74
+ # The peer went away; nothing to answer.
75
+ class ClientGone < StandardError; end
76
+
77
+ # The app handed the writer a header it must not write (ADR-0068 s2).
78
+ class UnsafeHeader < StandardError; end
79
+
80
+ attr_reader :request_timeout, :max_request_header, :max_upload_size
81
+
82
+ def initialize(server_name:)
83
+ @server_name = server_name
84
+ @listeners = []
85
+ @connections = {}
86
+ @lock = Mutex.new
87
+ @slot_freed = ConditionVariable.new
88
+ @running = false
89
+ @request_timeout = self.class.resolve_limit("TINA4_REQUEST_TIMEOUT", DEFAULT_REQUEST_TIMEOUT, zero_allowed: true)
90
+ @max_request_header = self.class.resolve_limit("TINA4_MAX_REQUEST_HEADER", DEFAULT_MAX_REQUEST_HEADER)
91
+ @max_upload_size = Tina4::Request.max_upload_size
92
+ end
93
+
94
+ # A numeric limit from the environment. A non-number or a negative value (or
95
+ # 0 where 0 would switch the guard off) warns and falls back, because a typo
96
+ # must never be the thing that disables a DoS guard. PHP Server::resolveLimit.
97
+ def self.resolve_limit(name, default, zero_allowed: false)
98
+ raw = ENV[name]
99
+ return default if raw.nil? || raw.strip.empty?
100
+
101
+ unless raw.strip.match?(/\A-?\d+\z/)
102
+ Tina4::Log.warning("#{name}=#{raw} is not a number - using #{default}")
103
+ return default
104
+ end
105
+ value = raw.to_i
106
+ if value.negative? || (value.zero? && !zero_allowed)
107
+ Tina4::Log.warning("#{name}=#{raw} is not a usable limit - using #{default}")
108
+ return default
109
+ end
110
+ value
111
+ end
112
+
113
+ # Bind a listener. Raises on a failed bind (the caller decides whether that
114
+ # is fatal - the main port is, a loopback sibling or the AI port is not).
115
+ def listen(host, port, app)
116
+ socket = TCPServer.new(host, port)
117
+ @listeners << Listener.new(socket, app, port.to_s)
118
+ socket
119
+ end
120
+
121
+ # Serve until #shutdown. Blocks on the FIRST listener's accept loop.
122
+ def start
123
+ @running = true
124
+ others = @listeners.drop(1).map { |listener| Thread.new { accept_loop(listener) } }
125
+ accept_loop(@listeners.first) if @listeners.first
126
+ others.each { |thread| thread.join(1) }
127
+ end
128
+
129
+ # Stop accepting: close every listener (a new connection is then REFUSED by
130
+ # the kernel) and close keep-alive connections waiting for their next
131
+ # request. A request already being served runs to completion; draining it is
132
+ # Tina4::Shutdown's job (TINA4_SHUTDOWN_TIMEOUT).
133
+ def shutdown
134
+ @running = false
135
+ @listeners.each do |listener|
136
+ listener.socket.close
137
+ rescue IOError, SystemCallError
138
+ # already closed
139
+ end
140
+ @lock.synchronize do
141
+ @connections.each do |socket, state|
142
+ next unless state == :idle
143
+
144
+ @connections[socket] = :closed
145
+ begin
146
+ socket.close
147
+ rescue IOError, SystemCallError
148
+ # already closed
149
+ end
150
+ end
151
+ @slot_freed.broadcast
152
+ end
153
+ end
154
+
155
+ def running?
156
+ @running
157
+ end
158
+
159
+ private
160
+
161
+ def accept_loop(listener)
162
+ while @running
163
+ wait_for_slot
164
+ break unless @running
165
+
166
+ begin
167
+ socket = listener.socket.accept
168
+ rescue IOError, Errno::EBADF, Errno::EINVAL
169
+ break # closed by #shutdown
170
+ rescue Errno::ECONNABORTED, Errno::EPROTO, Errno::ECONNRESET
171
+ next
172
+ rescue Errno::EMFILE, Errno::ENFILE, Errno::ENOBUFS, Errno::ENOMEM => e
173
+ Tina4::Log.warning("accept failed (#{e.class}), backing off")
174
+ sleep 0.1
175
+ next
176
+ end
177
+ @lock.synchronize { @connections[socket] = :idle }
178
+ thread = Thread.new(socket, listener) { |client, owner| serve_connection(client, owner) }
179
+ thread.report_on_exception = false
180
+ end
181
+ end
182
+
183
+ def wait_for_slot
184
+ @lock.synchronize do
185
+ @slot_freed.wait(@lock, 1) while @running && @connections.size >= MAX_CONNECTIONS
186
+ end
187
+ end
188
+
189
+ def serve_connection(socket, listener)
190
+ hijacked = false
191
+ begin
192
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
193
+ rescue SystemCallError, IOError
194
+ # not fatal - a unix-domain or odd socket
195
+ end
196
+ peer = peer_address(socket)
197
+ buffer = +"".b
198
+ loop do
199
+ outcome = serve_request(socket, buffer, listener, peer)
200
+ hijacked = outcome == :hijacked
201
+ break unless outcome == :keep_alive && @running
202
+ end
203
+ rescue ClientGone, IOError, SystemCallError
204
+ # the peer vanished, or #shutdown closed an idle keep-alive connection
205
+ rescue StandardError => e
206
+ Tina4::Log.error("HTTP connection error: #{e.class}: #{e.message}")
207
+ ensure
208
+ unless hijacked
209
+ begin
210
+ socket.close
211
+ rescue IOError, SystemCallError
212
+ # already closed
213
+ end
214
+ end
215
+ @lock.synchronize do
216
+ @connections.delete(socket)
217
+ @slot_freed.signal
218
+ end
219
+ end
220
+
221
+ # The raw TCP peer. Never empty: an empty REMOTE_ADDR would count as loopback
222
+ # in the dev gates, so a socket that cannot name its peer is not served.
223
+ def peer_address(socket)
224
+ address = socket.remote_address.ip_address.to_s
225
+ raise ClientGone if address.empty?
226
+
227
+ address.start_with?("::ffff:") && address.include?(".") ? address.delete_prefix("::ffff:") : address
228
+ end
229
+
230
+ # Serve ONE request off the connection. Returns :keep_alive, :close or
231
+ # :hijacked.
232
+ def serve_request(socket, buffer, listener, peer)
233
+ head = read_head(socket, buffer)
234
+ return :close if head.nil?
235
+
236
+ method, target, version, headers = parse_head(head)
237
+ body_bytes = read_body(socket, buffer, headers, version)
238
+ env = build_env(method, target, version, headers, body_bytes, listener, peer)
239
+ keep_alive = keep_alive?(version, headers)
240
+
241
+ if Tina4::Shutdown.shutting_down?
242
+ send_error(socket, 503, "Service shutting down")
243
+ return :close
244
+ end
245
+
246
+ hijacked = false
247
+ env["rack.hijack?"] = true
248
+ env["rack.hijack"] = lambda do
249
+ hijacked = true
250
+ env["rack.hijack_io"] = socket
251
+ end
252
+
253
+ Tina4::Shutdown.track_request do
254
+ status, response_headers, body = call_app(listener.app, env)
255
+ if hijacked
256
+ @lock.synchronize { @connections.delete(socket) }
257
+ next
258
+ end
259
+ keep_alive = write_response(socket, status, response_headers, body,
260
+ head_request: method == "HEAD", keep_alive: keep_alive && @running,
261
+ http10: version == "HTTP/1.0")
262
+ end
263
+ return :hijacked if hijacked
264
+
265
+ keep_alive ? :keep_alive : :close
266
+ rescue RequestRefused => e
267
+ send_error(socket, e.status, e.message)
268
+ :close
269
+ rescue UnsafeHeader => e
270
+ # Nothing of that response has been written; it is replaced whole.
271
+ Tina4::Log.error("Refused to write response header #{e.message}: " \
272
+ "a header name must be a token and a value may not contain CR, LF or NUL")
273
+ send_error(socket, 500, "Invalid response header")
274
+ :close
275
+ end
276
+
277
+ # A Rack app that raises gets a 500 rather than a dropped connection.
278
+ # (Tina4::RackApp rescues its own errors; this is for any other app.)
279
+ def call_app(app, env)
280
+ app.call(env)
281
+ rescue StandardError => e
282
+ Tina4::Log.error("Unhandled application error: #{e.class}: #{e.message}")
283
+ body = JSON.generate({ "error" => "Internal Server Error" })
284
+ [500, { "content-type" => "application/json" }, [body]]
285
+ end
286
+
287
+ # ── reading ─────────────────────────────────────────────────────────────
288
+
289
+ # Read up to the blank line that ends the head. nil when the client closed
290
+ # (or went idle) before sending anything - that is not a request, so it
291
+ # gets no answer. A client that STARTS a request and stalls gets 408.
292
+ def read_head(socket, buffer)
293
+ # RFC 9112 s2.2: ignore empty lines before a request line.
294
+ buffer.slice!(0, 2) while buffer.start_with?("\r\n")
295
+
296
+ if buffer.empty?
297
+ mark(socket, :idle)
298
+ return nil unless wait_for_bytes(socket, buffer, idle: true)
299
+
300
+ buffer.slice!(0, 2) while buffer.start_with?("\r\n")
301
+ end
302
+ mark(socket, :busy)
303
+
304
+ deadline = @request_timeout.positive? ? monotonic + @request_timeout : nil
305
+ until (head_end = buffer.index("\r\n\r\n"))
306
+ if buffer.bytesize > @max_request_header
307
+ raise RequestRefused.new(431, header_cap_message)
308
+ end
309
+ raise RequestRefused.new(408, TIMED_OUT) unless
310
+ wait_for_bytes(socket, buffer, deadline: deadline)
311
+ end
312
+ raise RequestRefused.new(431, header_cap_message) if head_end > @max_request_header
313
+
314
+ buffer.slice!(0, head_end + 4).byteslice(0, head_end)
315
+ end
316
+
317
+ # Append whatever the socket has. false on timeout; ClientGone on EOF
318
+ # mid-request; nil-safe EOF on an idle connection returns false.
319
+ def wait_for_bytes(socket, buffer, idle: false, deadline: nil)
320
+ timeout = if idle
321
+ @request_timeout.positive? ? @request_timeout : nil
322
+ elsif deadline
323
+ [deadline - monotonic, 0].max
324
+ end
325
+ return false unless socket.wait_readable(timeout)
326
+
327
+ buffer << socket.read_nonblock(READ_SIZE)
328
+ true
329
+ rescue IO::WaitReadable
330
+ true
331
+ rescue EOFError, Errno::ECONNRESET
332
+ raise ClientGone unless idle
333
+
334
+ false
335
+ end
336
+
337
+ def header_cap_message
338
+ "Request header fields exceed TINA4_MAX_REQUEST_HEADER (#{@max_request_header} bytes)"
339
+ end
340
+
341
+ # The head is split on CRLF, so any CR, LF or NUL left inside a line is a
342
+ # bare one. A name must be a token, which also refuses obs-fold (a
343
+ # continuation line starting with SP/HTAB, RFC 9112 s5.2).
344
+ def parse_head(head)
345
+ lines = head.split("\r\n", -1)
346
+ raise RequestRefused.new(400, MALFORMED_HEAD) if lines.any? { |line| line.match?(ILLEGAL_HEAD_BYTE) }
347
+
348
+ method, target, version, extra = lines.shift.to_s.split(" ", -1)
349
+ unless extra.nil? && method.to_s.match?(TOKEN) && target && !target.empty? &&
350
+ !target.match?(/[\x00-\x20\x7F]/) && %w[HTTP/1.1 HTTP/1.0].include?(version)
351
+ raise RequestRefused.new(400, MALFORMED_HEAD)
352
+ end
353
+
354
+ headers = lines.map do |line|
355
+ name, value = line.split(":", 2)
356
+ raise RequestRefused.new(400, MALFORMED_HEAD) if value.nil? || !name.match?(TOKEN)
357
+
358
+ [name.downcase, value.strip]
359
+ end
360
+ [method, target, version, headers]
361
+ end
362
+
363
+ def header_values(headers, name)
364
+ headers.filter_map { |key, value| value if key == name }
365
+ end
366
+
367
+ def read_body(socket, buffer, headers, version)
368
+ lengths = header_values(headers, "content-length")
369
+ encodings = header_values(headers, "transfer-encoding")
370
+
371
+ unless encodings.empty?
372
+ # Exactly "chunked", and never alongside Content-Length (smuggling).
373
+ unless lengths.empty? && encodings.length == 1 && encodings.first.casecmp?("chunked")
374
+ raise RequestRefused.new(400, INVALID_ENCODING)
375
+ end
376
+
377
+ send_continue(socket, headers, version)
378
+ return read_chunked(socket, buffer)
379
+ end
380
+ return +"".b if lengths.empty?
381
+
382
+ # Exactly one Content-Length, all digits. Two are refused even when they
383
+ # agree (ADR-0068): a proxy in front may pick the other one.
384
+ raise RequestRefused.new(400, INVALID_LENGTH) unless lengths.length == 1 && lengths.first.match?(/\A\d+\z/)
385
+
386
+ declared = lengths.first.to_i
387
+ # Refused on the DECLARED length, before one body byte is read.
388
+ raise RequestRefused.new(413, upload_cap_message(declared)) if declared > @max_upload_size
389
+
390
+ send_continue(socket, headers, version)
391
+ read_exactly(socket, buffer, declared)
392
+ end
393
+
394
+ def upload_cap_message(bytes)
395
+ "Request body (#{bytes} bytes) exceeds TINA4_MAX_UPLOAD_SIZE (#{@max_upload_size} bytes)"
396
+ end
397
+
398
+ # Expect: 100-continue - the client is waiting for permission before it
399
+ # sends the body. Only reached when the declared size already passed.
400
+ def send_continue(socket, headers, version)
401
+ return unless version == "HTTP/1.1"
402
+ return unless header_values(headers, "expect").any? { |value| value.casecmp?("100-continue") }
403
+
404
+ write_fully(socket, "HTTP/1.1 100 Continue\r\n\r\n")
405
+ end
406
+
407
+ # Body reads use an INACTIVITY timeout: a large upload on a slow link keeps
408
+ # going while bytes arrive; a peer that stops sending gets 408.
409
+ def read_exactly(socket, buffer, length)
410
+ fill(socket, buffer) while buffer.bytesize < length
411
+ buffer.slice!(0, length)
412
+ end
413
+
414
+ def read_line(socket, buffer, limit)
415
+ until (line_end = buffer.index("\r\n"))
416
+ raise RequestRefused.new(400, INVALID_ENCODING) if buffer.bytesize > limit
417
+
418
+ fill(socket, buffer)
419
+ end
420
+ buffer.slice!(0, line_end + 2).byteslice(0, line_end)
421
+ end
422
+
423
+ def fill(socket, buffer)
424
+ deadline = @request_timeout.positive? ? monotonic + @request_timeout : nil
425
+ return if wait_for_bytes(socket, buffer, deadline: deadline)
426
+
427
+ raise RequestRefused.new(408, TIMED_OUT)
428
+ end
429
+
430
+ def read_chunked(socket, buffer)
431
+ body = +"".b
432
+ loop do
433
+ size_line = read_line(socket, buffer, 1024)
434
+ size_text = size_line.split(";", 2).first.to_s.strip
435
+ raise RequestRefused.new(400, INVALID_ENCODING) unless size_text.match?(/\A\h{1,16}\z/)
436
+
437
+ size = size_text.to_i(16)
438
+ break if size.zero?
439
+
440
+ # The running counter: refuse the moment the ACTUAL bytes pass the cap.
441
+ if body.bytesize + size > @max_upload_size
442
+ raise RequestRefused.new(413, upload_cap_message(body.bytesize + size))
443
+ end
444
+
445
+ body << read_exactly(socket, buffer, size)
446
+ raise RequestRefused.new(400, INVALID_ENCODING) unless read_exactly(socket, buffer, 2) == "\r\n"
447
+ end
448
+ # Trailer section: header lines up to an empty line, bounded like headers.
449
+ trailer_bytes = 0
450
+ loop do
451
+ line = read_line(socket, buffer, @max_request_header)
452
+ break if line.empty?
453
+
454
+ trailer_bytes += line.bytesize
455
+ raise RequestRefused.new(431, header_cap_message) if trailer_bytes > @max_request_header
456
+ end
457
+ body
458
+ end
459
+
460
+ def keep_alive?(version, headers)
461
+ tokens = header_values(headers, "connection").join(",").downcase.split(",").map(&:strip)
462
+ return false if tokens.include?("close")
463
+
464
+ version == "HTTP/1.1" || tokens.include?("keep-alive")
465
+ end
466
+
467
+ # ── the Rack env ────────────────────────────────────────────────────────
468
+
469
+ def build_env(method, target, version, headers, body_bytes, listener, peer)
470
+ target = absolute_form_path(target)
471
+ raw_path, query = target.split("?", 2)
472
+ path_info = normalize_path(percent_decode(raw_path.to_s))
473
+
474
+ env = {
475
+ "REQUEST_METHOD" => method,
476
+ "SCRIPT_NAME" => "",
477
+ "PATH_INFO" => path_info,
478
+ "REQUEST_PATH" => raw_path.to_s,
479
+ "REQUEST_URI" => target,
480
+ "QUERY_STRING" => query.to_s,
481
+ "SERVER_NAME" => @server_name,
482
+ "SERVER_PORT" => listener.port,
483
+ "SERVER_PROTOCOL" => version,
484
+ "HTTP_VERSION" => version,
485
+ "SERVER_SOFTWARE" => SOFTWARE,
486
+ "REMOTE_ADDR" => peer,
487
+ "CONTENT_TYPE" => header_values(headers, "content-type").last.to_s,
488
+ "CONTENT_LENGTH" => body_bytes.bytesize.to_s,
489
+ "rack.input" => StringIO.new(body_bytes),
490
+ "rack.errors" => $stderr,
491
+ "rack.url_scheme" => "http",
492
+ "rack.version" => [1, 3],
493
+ "rack.multithread" => true,
494
+ "rack.multiprocess" => false,
495
+ "rack.run_once" => false
496
+ }
497
+
498
+ # A dashed name wins over an underscored one that maps to the same env
499
+ # key, whatever the order: "X_Forwarded_For" must not clobber a proxy's
500
+ # X-Forwarded-For.
501
+ dashed, underscored = headers.partition { |name, _| !name.include?("_") }
502
+ (dashed + underscored).each do |name, value|
503
+ next if name == "content-type" || name == "content-length" || name == "transfer-encoding"
504
+
505
+ key = "HTTP_#{name.upcase.tr('-', '_')}"
506
+ if name.include?("_") && dashed.any? { |other, _| "HTTP_#{other.upcase.tr('-', '_')}" == key }
507
+ next
508
+ end
509
+
510
+ env[key] = env.key?(key) ? "#{env[key]}#{name == 'cookie' ? '; ' : ', '}#{value}" : value
511
+ end
512
+ env
513
+ end
514
+
515
+ def absolute_form_path(target)
516
+ return target unless target.match?(%r{\Ahttps?://}i)
517
+
518
+ path = target.sub(%r{\Ahttps?://[^/?]*}i, "")
519
+ path.start_with?("/") ? path : "/#{path}"
520
+ end
521
+
522
+ def percent_decode(path)
523
+ path.b.gsub(/%\h\h/) { |escape| escape[1, 2].hex.chr }
524
+ end
525
+
526
+ # The same normalisation the previous built-in server (WEBrick) applied
527
+ # before routing: collapse "//", resolve "." and ".." segments, and refuse a
528
+ # path that climbs above the root or carries a NUL.
529
+ def normalize_path(path)
530
+ raise RequestRefused.new(400, MALFORMED_HEAD) if path.include?("\0")
531
+ return path if path == "*"
532
+ raise RequestRefused.new(400, MALFORMED_HEAD) unless path.start_with?("/")
533
+
534
+ normalized = path.gsub(%r{/+}, "/")
535
+ nil while normalized.sub!(%r{/\.(?:/|\z)}, "/")
536
+ nil while normalized.sub!(%r{/(?!\.\./)[^/]+/\.\.(?:/|\z)}, "/")
537
+ raise RequestRefused.new(400, MALFORMED_HEAD) if normalized.match?(%r{/\.\.(?:/|\z)})
538
+
539
+ normalized
540
+ end
541
+
542
+ # ── writing ─────────────────────────────────────────────────────────────
543
+
544
+ # Returns whether the connection may be reused.
545
+ def write_response(socket, status, headers, body, head_request:, keep_alive:, http10:)
546
+ status = status.to_i
547
+ buffered = body.is_a?(Array)
548
+ no_body = head_request || STATUSES_WITHOUT_BODY.include?(status) || status < 200
549
+ lines = ["HTTP/1.1 #{status} #{Tina4.http_reason(status)}"]
550
+ declared_length = nil
551
+
552
+ header_lines(headers).each do |name, value|
553
+ lowered = name.downcase
554
+ case lowered
555
+ when "connection"
556
+ keep_alive = false if value.downcase.include?("close")
557
+ next
558
+ when "transfer-encoding"
559
+ next # framing is the server's, never the app's
560
+ when "content-length"
561
+ declared_length = value
562
+ next
563
+ end
564
+ lines << "#{name}: #{value}"
565
+ end
566
+
567
+ chunks = buffered ? body.map(&:to_s) : nil
568
+ if status < 200 || STATUSES_WITHOUT_BODY.include?(status)
569
+ # 304 may state the representation's length; 1xx and 204 never do.
570
+ lines << "content-length: #{declared_length}" if declared_length && status == 304
571
+ elsif buffered
572
+ # HEAD reports the length the GET would have sent (the app computed it).
573
+ length_value = head_request && declared_length ? declared_length : chunks.sum(&:bytesize).to_s
574
+ lines << "content-length: #{length_value}"
575
+ elsif declared_length
576
+ lines << "content-length: #{declared_length}"
577
+ else
578
+ # A stream of unknown length is delimited by closing the connection
579
+ # (PHP streamToClient / Python's built-in server do the same).
580
+ keep_alive = false unless no_body
581
+ end
582
+
583
+ if keep_alive
584
+ lines << "connection: keep-alive" if http10
585
+ else
586
+ lines << "connection: close"
587
+ end
588
+
589
+ head = "#{lines.join("\r\n")}\r\n\r\n".b
590
+ if no_body
591
+ write_fully(socket, head)
592
+ elsif buffered
593
+ payload = chunks.sum(&:bytesize) <= 65_536 ? head + chunks.join.b : nil
594
+ if payload
595
+ write_fully(socket, payload)
596
+ else
597
+ write_fully(socket, head)
598
+ chunks.each { |chunk| write_fully(socket, chunk.b) }
599
+ end
600
+ else
601
+ write_fully(socket, head)
602
+ body.each { |chunk| write_fully(socket, chunk.to_s.b) unless chunk.to_s.empty? }
603
+ end
604
+ keep_alive
605
+ ensure
606
+ body.close if body.respond_to?(:close)
607
+ end
608
+
609
+ # Every [name, value] line to write, checked BEFORE any byte goes out. An
610
+ # Array value (Rack 3) or a "\n"-joined Set-Cookie (Rack 2, and
611
+ # Tina4::Response#to_rack) is one line per element. A name that is not a
612
+ # token, or a value carrying CR, LF or NUL, raises UnsafeHeader (ADR-0068):
613
+ # it would let the value choose its own headers - or a second response.
614
+ # Response#header refuses these at the call site already; this catches a
615
+ # header appended to the hash directly.
616
+ def header_lines(headers)
617
+ headers.flat_map do |name, value|
618
+ name = name.to_s
619
+ next [] if name.start_with?("rack.")
620
+
621
+ values = if value.is_a?(Array)
622
+ value.map(&:to_s)
623
+ elsif name.casecmp?("set-cookie")
624
+ value.to_s.split("\n")
625
+ else
626
+ [value.to_s]
627
+ end
628
+ values.map do |single|
629
+ raise UnsafeHeader, JSON.generate(name) if !name.match?(TOKEN) || single.match?(ILLEGAL_RESPONSE_VALUE)
630
+
631
+ [name, single]
632
+ end
633
+ end
634
+ end
635
+
636
+ # Write every byte, or give up when the peer stops reading for
637
+ # TINA4_REQUEST_TIMEOUT (a slow reader must not hold a thread forever).
638
+ def write_fully(socket, data)
639
+ data = data.b
640
+ offset = 0
641
+ while offset < data.bytesize
642
+ begin
643
+ offset += socket.write_nonblock(data.byteslice(offset, data.bytesize - offset))
644
+ rescue IO::WaitWritable
645
+ timeout = @request_timeout.positive? ? @request_timeout : nil
646
+ raise ClientGone unless socket.wait_writable(timeout)
647
+ end
648
+ end
649
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
650
+ raise ClientGone
651
+ end
652
+
653
+ # A transport rejection, in ADR-0068's one shape: the JSON {"error": ...}
654
+ # body, the canonical security headers (no HSTS - the scheme is not known
655
+ # yet) and Connection: close. The close is a lingering one - stop writing,
656
+ # throw away what the client is still sending for up to LINGER_SECONDS -
657
+ # so a client mid-upload reads the answer instead of a reset.
658
+ def send_error(socket, status, message)
659
+ body = JSON.generate({ "error" => message })
660
+ lines = ["HTTP/1.1 #{status} #{Tina4.http_reason(status)}",
661
+ "Content-Type: application/json",
662
+ "Content-Length: #{body.bytesize}",
663
+ "Connection: close"]
664
+ Tina4::SecurityHeadersMiddleware.canonical_headers.each do |name, value|
665
+ lines << "#{name}: #{value}" unless value.to_s.match?(ILLEGAL_RESPONSE_VALUE)
666
+ end
667
+ write_fully(socket, "#{lines.join("\r\n")}\r\n\r\n#{body}")
668
+ socket.close_write
669
+ deadline = monotonic + LINGER_SECONDS
670
+ discard = +"".b # one reused buffer: draining must not allocate per read
671
+ while (remaining = deadline - monotonic).positive? && socket.wait_readable(remaining)
672
+ socket.read_nonblock(READ_SIZE, discard)
673
+ end
674
+ rescue ClientGone, IOError, SystemCallError, IO::WaitReadable
675
+ # the peer is gone - nothing more to say
676
+ end
677
+
678
+ def mark(socket, state)
679
+ @lock.synchronize do
680
+ raise ClientGone if @connections[socket] == :closed
681
+
682
+ @connections[socket] = state
683
+ end
684
+ end
685
+
686
+ def monotonic
687
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
688
+ end
689
+ end
690
+ end