otto 2.10.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebb9d40d464e54cb0a25d0014dfa6868905e833c400701739d61463ae56b7265
4
- data.tar.gz: f111ef655d58256cece33215fe05a7711c5f1bd0e06925c41e58b0e492f6dfb5
3
+ metadata.gz: b8303453d265fcf2d5b0fa0754aafdbdb3e24eb89be9dc5a7b5611b4f2bcc699
4
+ data.tar.gz: 2081706f65a3475f95555d96547841691f906b015f8b012739abc7804923fc76
5
5
  SHA512:
6
- metadata.gz: c1381ef06000d127d7411f50e797dbf14720821873bc8f9b70e1e01a3a232fb7a8f2ebaee7e0ec4ed8a9d61dfc8cf8d67753de21702ef253dd42b4e1736949ea
7
- data.tar.gz: 7105c5561be5e5f3cfe771b41cc1d576c0ede262cecc92347cc518cbdd0c57deaefca50a923a535661a47ac6a977a1171a6dab9cb3f9ff59ca993c5a1a2d326a
6
+ metadata.gz: 75c496ee29c9c4ec0b05a8effda77e4e1dafc02080fb341a2320313011437f2b0bc0511ad6ca7d1b53928d6f75130693c974c72ed053aa35e5c6d16f60af0a5e
7
+ data.tar.gz: 9d9615efa73c7ebafafe7f7e1eb3e098f8c07f3200ac62882c52c39954087c73053fc9574beda1b1e11ecc836e0b9bacb70fe267c90fa92600531ef14fca1b63
@@ -61,7 +61,7 @@ jobs:
61
61
 
62
62
  - name: Run Claude Code Review
63
63
  id: claude-review
64
- uses: anthropics/claude-code-action@d75b94d5ad426cb8546e6628b6f5f19b84e5cce1 # v1.0.216
64
+ uses: anthropics/claude-code-action@19dda84776b3518d98b8798e591daee763049ed3 # v1.0.220
65
65
  with:
66
66
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
67
67
 
@@ -32,7 +32,7 @@ jobs:
32
32
 
33
33
  - name: Run Claude Code
34
34
  id: claude
35
- uses: anthropics/claude-code-action@d75b94d5ad426cb8546e6628b6f5f19b84e5cce1 # v1.0.216
35
+ uses: anthropics/claude-code-action@19dda84776b3518d98b8798e591daee763049ed3 # v1.0.220
36
36
  with:
37
37
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
38
38
 
data/.rubocop_todo.yml CHANGED
@@ -859,6 +859,7 @@ RSpec/SpecFilePathFormat:
859
859
  - 'spec/otto/enhanced_routing_spec.rb'
860
860
  - 'spec/otto/error_handler_registration_spec.rb'
861
861
  - 'spec/otto/error_handling_spec.rb'
862
+ - 'spec/otto/fallback_response_isolation_spec.rb'
862
863
  - 'spec/otto/file_safety_spec.rb'
863
864
  - 'spec/otto/locale_config_spec.rb'
864
865
  - 'spec/otto/mcp/rate_limiting_spec.rb'
data/AGENTS.md CHANGED
@@ -69,6 +69,24 @@ Helper modules should avoid overriding these methods inherited from Rack::Reques
69
69
 
70
70
  No runtime validation is performed for performance reasons. Overriding these methods will cause undefined behavior.
71
71
 
72
+ ## Static File Registration
73
+
74
+ Files under the `public:` directory are served without registration. Use
75
+ `mount_static` to bind a URL prefix to a directory outside it, or to verify a
76
+ required asset directory at boot:
77
+
78
+ ```ruby
79
+ otto = Otto.new('routes.txt', public: 'public')
80
+ otto.mount_static('/assets', root: 'build/assets')
81
+ ```
82
+
83
+ - Roots are canonicalized at registration; a missing or unsafe root raises `ArgumentError`
84
+ - Precedence is fixed: literal routes, then mounts (longest prefix first), then `public:`, then dynamic routes
85
+ - Must be registered before first request (before configuration freezing)
86
+ - `add_static_path` was removed in v2.10.0 and has no shim
87
+
88
+ See `docs/guides/routing.md` for the full contract.
89
+
72
90
  ## Authentication Architecture
73
91
 
74
92
  Authentication is handled by `RouteAuthWrapper` at the handler level, NOT by middleware.
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,38 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
7
7
 
8
8
  <!--scriv-insert-here-->
9
9
 
10
+ .. _changelog-2.11.0:
11
+
12
+ 2.11.0 — 2026-09-12
13
+ ===================
14
+
15
+ Added
16
+ -----
17
+
18
+ - ``Otto#mount_static(prefix, root:)`` serves an explicit directory at a URL
19
+ prefix. See ``docs/guides/routing.md`` for configuration, dispatch
20
+ precedence, and migration from ``add_static_path``. (#267)
21
+
22
+ - ``Otto#not_found=`` and ``Otto#server_error=`` now accept callables for
23
+ per-request fallback responses. A server-error callable can receive the
24
+ exception; see ``docs/guides/routing.md`` for the callback contract. (#272)
25
+
26
+ Security
27
+ --------
28
+
29
+ - Static Rack triples configured with ``Otto#not_found=`` or
30
+ ``Otto#server_error=`` are now copied for each request, preventing in-place
31
+ header changes, including ``Set-Cookie``, from being shared between fallback
32
+ responses. (#272)
33
+
34
+ Documentation
35
+ -------------
36
+
37
+ - Documented static-file dispatch precedence and migration from the removed
38
+ ``add_static_path`` API in ``docs/guides/routing.md``. Corrected stale
39
+ ``routes_static`` cache guidance in ``docs/guides/configuration_freezing.md``.
40
+ (#267)
41
+
10
42
  .. _changelog-2.10.0:
11
43
 
12
44
  2.10.0 — 2026-09-04
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- otto (2.10.0)
4
+ otto (2.11.0)
5
5
  concurrent-ruby (~> 1.3, < 2.0)
6
6
  logger (~> 1, < 2.0)
7
7
  loofah (~> 2.20)
@@ -51,15 +51,17 @@ otto.add_auth_strategy('other', MyApp::OtherStrategy.new)
51
51
  - the middleware stack;
52
52
  - authentication configuration and instance options;
53
53
  - dynamic and literal route tables;
54
- - route definitions and reverse-route indexes.
54
+ - route definitions and reverse-route indexes;
55
+ - the explicit static mount table (`mount_static`).
55
56
 
56
57
  Hashes and arrays inside those structures are recursively frozen. Configuration
57
58
  objects that implement `deep_freeze!` can prepare memoized values before they
58
59
  are frozen.
59
60
 
60
- The static-file route structure is an intentional exception. Its outer hash is
61
- frozen, but the `routes_static[:GET]` `Concurrent::Map` remains writable because
62
- Otto caches newly discovered static paths during requests.
61
+ Static-file dispatch keeps no mutable routing state. Files under the implicit
62
+ `public:` directory are resolved on each request against the current canonical
63
+ root, and explicit mounts are an immutable snapshot from the moment they are
64
+ registered; `mount_static` raises `FrozenError` after the freeze boundary.
63
65
 
64
66
  ## Scope and current limitations
65
67
 
@@ -69,8 +71,12 @@ some state that is not included in `freeze_configuration!`:
69
71
 
70
72
  - `error_handlers` remains a mutable Hash, although
71
73
  `register_error_handler` rejects calls after freezing;
72
- - the `not_found` and `server_error` fallback response writers remain available;
73
- - the inner static-file cache remains mutable by design.
74
+ - the `not_found` and `server_error` fallback response writers remain
75
+ available. A configured static triple is never returned by reference:
76
+ Otto copies it per request, so header writes by cookie middleware do not
77
+ reach the configured object even though it is not frozen;
78
+ - the `Rack::Files` instance for the implicit `public:` directory is rebuilt
79
+ when that directory is repointed between requests.
74
80
 
75
81
  Application code should not mutate those objects directly after boot. Do not
76
82
  state or depend on a guarantee that every object reachable from an Otto instance
@@ -86,6 +92,9 @@ otto.security_config.disable_csrf_protection!
86
92
  otto.add_trusted_proxy('192.0.2.10')
87
93
  otto.add_rate_limit_rule('uploads', limit: 5, period: 60)
88
94
 
95
+ # Static mounts
96
+ otto.mount_static('/assets', root: 'public/assets')
97
+
89
98
  # Middleware and authentication
90
99
  otto.use MyApp::OtherMiddleware
91
100
  otto.add_auth_strategy('other', MyApp::OtherStrategy.new)
@@ -144,3 +153,5 @@ first requests therefore do not run the freeze operation concurrently.
144
153
  freezing behavior.
145
154
  - [Configuration-freezing specs](../../spec/otto/configuration_freezing_spec.rb).
146
155
  - [Static-file freezing specs](../../spec/otto/static_file_freezing_spec.rb).
156
+ - [Static mount specs](../../spec/otto/core/static_mounts_spec.rb) — registration
157
+ after the freeze boundary and serving from a frozen mount table.
@@ -163,6 +163,118 @@ silently weakening the route. Do not use `csrf=exempt` as a general API switch;
163
163
  choose an independent request-authentication and replay-protection model for
164
164
  webhooks or other non-browser endpoints.
165
165
 
166
+ ## Static files
167
+
168
+ Otto serves static files in two ways. Both apply the same safety policy: the
169
+ requested path is joined to a canonical root, resolved with `File.realpath`
170
+ (which follows every `..`, `.`, and symlink component), and served only when
171
+ the result is still inside that root and is a regular, readable file owned by
172
+ the process user or group. Anything else, including a symlink that points
173
+ outside the root, is treated as not found.
174
+
175
+ ### Implicit public directory
176
+
177
+ Passing `public:` serves every file under that directory at its relative path.
178
+ Nothing needs registering; a file added after boot is served on the next
179
+ request, and a symlinked public directory that is repointed by a deploy is
180
+ re-resolved on every request.
181
+
182
+ ```ruby
183
+ otto = Otto.new('routes', public: File.expand_path('public', __dir__))
184
+ # public/css/site.css is served at GET /css/site.css
185
+ ```
186
+
187
+ ### Explicit static mounts
188
+
189
+ `mount_static` binds one URL prefix to one directory. Use it when the files do
190
+ not live under a single public directory, when a URL prefix should map to a
191
+ different directory name, or when a required asset directory must be verified
192
+ at boot.
193
+
194
+ ```ruby
195
+ otto = Otto.new('routes')
196
+ otto.mount_static('/assets', root: 'public/assets')
197
+ otto.mount_static('/vendor', root: File.join(Gem.loaded_specs['some-ui-kit'].full_gem_path, 'dist'))
198
+ otto.mount_static('/', root: 'public/root-files') # favicon.ico, robots.txt
199
+ ```
200
+
201
+ - The prefix must start with `/`. A trailing slash is ignored, and `/`
202
+ mounts the root at the top level. Empty, `.`, and `..` segments are
203
+ rejected.
204
+ - The root is expanded and canonicalized once, at registration. A root that
205
+ is missing, unreadable, not a directory, not owned by the process user or
206
+ group, or a symlink that cannot be resolved raises `ArgumentError`, so a
207
+ misconfigured application does not boot. Because the root is fixed at
208
+ registration, a deploy that repoints a symlinked root takes effect at the
209
+ next restart.
210
+ - A mount authorizes only files inside its own root. It never exposes the
211
+ root's parent or siblings, and it does not widen the implicit public
212
+ directory. Registering the same prefix twice on one instance raises
213
+ `ArgumentError`; different Otto instances are fully independent.
214
+ - Requests are matched on the decoded, trailing-slash-stripped path, the same
215
+ normalization every other dispatch stage uses. Only `GET` is served, the
216
+ prefix itself is not (mounts serve files, not directory listings), and a
217
+ request for a file the root does not contain falls through to the next
218
+ dispatch stage.
219
+ - `mount_static` must be called before the first request. After configuration
220
+ freezing it raises `FrozenError`, and `otto.static_mounts` is a frozen,
221
+ read-only table.
222
+
223
+ ### Dispatch precedence
224
+
225
+ Precedence is fixed and does not depend on request history:
226
+
227
+ 1. literal routes, such as `GET /assets/app.css Assets#show`;
228
+ 2. explicit static mounts, consulted longest prefix first; when the longest
229
+ matching mount does not contain the file, shorter matching mounts are tried
230
+ in turn;
231
+ 3. the implicit `public:` directory;
232
+ 4. dynamic routes, such as `GET /assets/:name Assets#show`.
233
+
234
+ So a literal route at a mounted path always wins, a mounted file always beats
235
+ a file at the same URL in the public directory, and a dynamic route only sees
236
+ requests that no static source could serve.
237
+
238
+ ### Migrating from `add_static_path`
239
+
240
+ `add_static_path` was removed in v2.10.0. It only populated a request-time
241
+ cache; it never registered or restricted anything. Callers that used it to
242
+ "register" files under the public directory can delete the call, because the
243
+ public directory is served without registration. Callers that used it to reach
244
+ files outside the public directory should replace it with `mount_static` and
245
+ an explicit root. There is no compatibility shim: calling the removed method
246
+ raises `NoMethodError` at boot.
247
+
248
+ ## Fallback 404 and 500 responses
249
+
250
+ A `GET /404` or `GET /500` route in the routes file handles misses and
251
+ unhandled errors like any other route. Without one, Otto uses `not_found=` and
252
+ `server_error=`, which accept either a Rack triple or a callable:
253
+
254
+ ```ruby
255
+ otto.not_found = [404, { 'content-type' => 'application/json' }, ['{"error":"Not Found"}']]
256
+
257
+ otto.server_error = lambda do |env, error|
258
+ [500, { 'content-type' => 'text/plain' }, ["Error #{env['otto.error_id']}"]]
259
+ end
260
+ ```
261
+
262
+ A callable is invoked on every request with `env` (`not_found`) or `env` and
263
+ the exception (`server_error`), trimmed to the positional parameters it
264
+ declares, so `->(env) { ... }` and `->(env = nil) { ... }` both work for
265
+ `server_error`. It must return a Rack triple: an Integer status, Hash-like
266
+ headers, and a body that responds to `each` (a bare String is rejected, at
267
+ assignment time for a static triple). A static triple is copied per request
268
+ before it is returned, so middleware that writes response headers in place
269
+ (rack-session, Otto's CSRF middleware, anything calling
270
+ `Rack::Utils.set_cookie_header!`) never mutates the configured object or
271
+ leaks one client's `Set-Cookie` into another's response. Do not rely on
272
+ mutating the configured triple after boot; assign a new value or use the
273
+ callable form instead.
274
+
275
+ For JSON clients, an unhandled error returns Otto's built-in JSON error body
276
+ regardless of `server_error`; a `/500` route applies to every client.
277
+
166
278
  ## Configuration timing
167
279
 
168
280
  Construct and configure the Otto instance before the first request:
@@ -308,11 +308,14 @@ class Otto
308
308
  deep_freeze_value(@routes_literal) if @routes_literal
309
309
  deep_freeze_value(@route_definitions) if @route_definitions
310
310
  deep_freeze_value(@routes_by_definition) if @routes_by_definition
311
+ # Explicit static mounts are already immutable snapshots; freezing the
312
+ # array here records that fact and makes any in-place mutation raise.
313
+ deep_freeze_value(@static_mounts) if @static_mounts
311
314
 
312
315
  @configuration_frozen = true
313
316
 
314
317
  duration = Otto::Utils.now_in_μs - start_time
315
- frozen_objects = %w[security_config locale_config middleware auth_config option routes]
318
+ frozen_objects = %w[security_config locale_config middleware auth_config option routes static_mounts]
316
319
  Otto.structured_log(:info, 'Freezing completed',
317
320
  {
318
321
  duration: duration,
@@ -71,8 +71,8 @@ class Otto
71
71
  # Content negotiation for built-in error response
72
72
  return json_error_response(error_id) if wants_json_response?(env)
73
73
 
74
- # Fallback to built-in error response
75
- @server_error || secure_error_response(error_id)
74
+ # Fallback to the configured server_error response, else the built-in one
75
+ server_error_response(env, error, error_id)
76
76
  end
77
77
 
78
78
  # Register an error handler for expected business logic errors
@@ -268,6 +268,44 @@ class Otto
268
268
  end
269
269
  end
270
270
 
271
+ # Build the fallback 500 response for an unhandled error.
272
+ #
273
+ # A configured +server_error+ callable is invoked per request with
274
+ # +env+ and the original +error+, trimmed to the positional parameters
275
+ # it declares; +env+ carries +otto.error_id+ so the response can
276
+ # reference the logged error. A configured static triple is copied per
277
+ # request (see {Otto::Static.copy_response}) so header writes by cookie
278
+ # middleware cannot accumulate on the shared object. A callable that
279
+ # raises is logged and replaced by the built-in secure response,
280
+ # mirroring how a failing custom +/500+ route is handled.
281
+ #
282
+ # @param env [Hash] Rack environment
283
+ # @param error [Exception] the unhandled error
284
+ # @param error_id [String] correlation id already logged for +error+
285
+ # @return [Array] a fresh Rack triple
286
+ def server_error_response(env, error, error_id)
287
+ fallback = @server_error
288
+ return secure_error_response(error_id) if fallback.nil?
289
+
290
+ env['otto.error_id'] = error_id
291
+ resolve_fallback_response(:server_error, fallback, env, error)
292
+ rescue StandardError => e
293
+ fallback_error_id = SecureRandom.hex(8)
294
+ base_context = Otto::LoggingHelpers.request_context(env)
295
+
296
+ Otto.structured_log(:error, 'Error in server_error fallback',
297
+ base_context.merge(
298
+ error: e.message,
299
+ error_class: e.class.name,
300
+ error_id: fallback_error_id,
301
+ original_error_id: error_id
302
+ ))
303
+ Otto::LoggingHelpers.log_backtrace(e,
304
+ base_context.merge(error_id: fallback_error_id, original_error_id: error_id))
305
+
306
+ secure_error_response(error_id)
307
+ end
308
+
271
309
  def secure_error_response(error_id)
272
310
  body = if Otto.env?(:dev, :development)
273
311
  "Server error (ID: #{error_id}). Check logs for details."
@@ -36,16 +36,32 @@ class Otto
36
36
  # callers never have to re-run realpath (one resolution per request).
37
37
  StaticFile = Struct.new(:root, :path, :relative)
38
38
 
39
- # Resolve a request path to a canonical, contained, servable file.
39
+ # Resolve a request path to a canonical, contained, servable file under
40
+ # the implicit +public:+ directory.
40
41
  #
41
42
  # @param path [String, nil] request-relative path (may start with '/')
42
43
  # @return [StaticFile, nil] the validated file, or nil when unsafe
43
44
  def resolve_static_file(path)
44
45
  return nil if option[:public].nil? || option[:public].empty?
45
- return nil if path.nil? || path.empty?
46
46
 
47
- public_dir = canonical_public_dir
48
- return nil if public_dir.nil?
47
+ resolve_file_under(canonical_public_dir, path)
48
+ end
49
+
50
+ # Resolve +path+ against an already-canonical +root+ and return it only
51
+ # when it is a contained, readable, owned regular file.
52
+ #
53
+ # Shared by the implicit public directory and explicit static mounts
54
+ # (Otto::Core::StaticMounts) so both apply one containment policy.
55
+ # +root+ must be a File.realpath result: containment compares canonical
56
+ # strings on a separator boundary, so a non-canonical root would never
57
+ # match the canonicalized candidate.
58
+ #
59
+ # @param root [String, nil] canonical directory
60
+ # @param path [String, nil] root-relative path (may start with '/')
61
+ # @return [StaticFile, nil] the validated file, or nil when unsafe
62
+ def resolve_file_under(root, path)
63
+ return nil if root.nil? || root.empty?
64
+ return nil if path.nil? || path.empty?
49
65
 
50
66
  # A NUL byte in a request path is never legitimate; it is a truncation
51
67
  # attack on downstream C string handling. Reject it rather than
@@ -57,18 +73,18 @@ class Otto
57
73
 
58
74
  # Join, then canonicalize: realpath resolves '..', '.' AND every
59
75
  # symlink component, so the containment check below cannot be fooled
60
- # by a link that points outside the public directory.
61
- candidate = File.join(public_dir, clean_path)
76
+ # by a link that points outside the root.
77
+ candidate = File.join(root, clean_path)
62
78
  real_path = safe_realpath(candidate)
63
79
  return nil if real_path.nil?
64
80
 
65
- return nil unless contained?(real_path, public_dir)
81
+ return nil unless contained?(real_path, root)
66
82
 
67
83
  # Second gate: it must be a readable regular file we (or our group) own.
68
84
  return nil unless File.file?(real_path) && File.readable?(real_path)
69
85
  return nil unless File.owned?(real_path) || File.grpowned?(real_path)
70
86
 
71
- StaticFile.new(public_dir, real_path, real_path.delete_prefix(public_dir + File::SEPARATOR))
87
+ StaticFile.new(root, real_path, real_path.delete_prefix(root + File::SEPARATOR))
72
88
  end
73
89
 
74
90
  def safe_file?(path)
@@ -142,9 +142,12 @@ class Otto
142
142
 
143
143
  static_candidate = !static_route.nil? && http_verb == :GET
144
144
 
145
- # Dispatch precedence is fixed: literal routes, then static files, then
146
- # dynamic routes. Static-file requests always pass through containment
147
- # validation before they are served (issues #257 and #260).
145
+ # Dispatch precedence is fixed: literal routes, then explicit static
146
+ # mounts (longest prefix first), then the implicit public directory,
147
+ # then dynamic routes. Every static-file request passes through
148
+ # containment validation before it is served (issues #257, #260 and
149
+ # #267). A mount or the public directory claims files, not paths: when
150
+ # the file is absent the request falls through to the next stage.
148
151
  if literal_routes.has_key?(path_info_clean)
149
152
  route = literal_routes[path_info_clean]
150
153
  Otto.structured_log(:debug, 'Route matched',
@@ -158,6 +161,13 @@ class Otto
158
161
  @route_matched_callbacks.each { |cb| cb.call(env, route.route_definition) }
159
162
  end
160
163
  route.call(env)
164
+ elsif http_verb == :GET && (mounted = resolve_mounted_file(dispatch_path))
165
+ mount, static_file = mounted
166
+ Otto.structured_log(:debug, 'Route matched',
167
+ Otto::LoggingHelpers.request_context(env).merge(
168
+ type: 'static_mount', prefix: mount.display_prefix
169
+ ))
170
+ serve_static_file(env, static_file, mount.files)
161
171
  elsif static_candidate && (static_file = resolve_static_file(dispatch_path))
162
172
  Otto.structured_log(:debug, 'Route matched',
163
173
  Otto::LoggingHelpers.request_context(env).merge(type: 'static'))
@@ -201,12 +211,16 @@ class Otto
201
211
  # Rack::Files could still redirect the open. Closing that requires an
202
212
  # O_NOFOLLOW-per-component or fd-based serve, i.e. replacing
203
213
  # Rack::Files. Accepted for now; see issue #257.
204
- def serve_static_file(env, static_file)
214
+ #
215
+ # +files+ is the Rack::Files instance rooted at +static_file.root+: a
216
+ # mount's own frozen instance, or (by default) the public-directory one
217
+ # that #static_route_for keeps in step with the current root.
218
+ def serve_static_file(env, static_file, files = static_route_for(static_file.root))
205
219
  static_env = env.dup
206
220
  # Rack::Files unescapes PATH_INFO, so escape the canonical path to
207
221
  # survive the round trip (escape_path preserves '/').
208
222
  static_env['PATH_INFO'] = "/#{Rack::Utils.escape_path(static_file.relative)}"
209
- static_route_for(static_file.root).call(static_env)
223
+ files.call(static_env)
210
224
  end
211
225
 
212
226
  # Rack::Files rooted at the root +static_file+ was validated against.
@@ -287,10 +301,29 @@ class Otto
287
301
  Otto::LoggingHelpers.request_context(env).merge(
288
302
  fallback_to: 'default_not_found'
289
303
  ))
290
- @not_found || Otto::Static.not_found
304
+ not_found_response(env)
291
305
  end
292
306
  end
293
307
 
308
+ # Build the response for a request that matched no route and has no
309
+ # +/404+ route configured.
310
+ #
311
+ # A configured +not_found+ callable is invoked with +env+ on every miss.
312
+ # A configured static triple is copied per request (see
313
+ # {Otto::Static.copy_response}) so header writes by cookie middleware
314
+ # cannot accumulate on, or leak between requests through, the shared
315
+ # object. With nothing configured the built-in {Otto::Static.not_found}
316
+ # response is used.
317
+ #
318
+ # @param env [Hash] Rack environment
319
+ # @return [Array] a fresh Rack triple
320
+ def not_found_response(env)
321
+ fallback = @not_found
322
+ return Otto::Static.not_found if fallback.nil?
323
+
324
+ resolve_fallback_response(:not_found, fallback, env)
325
+ end
326
+
294
327
  def build_route_params(route, values)
295
328
  if route.keys.any?
296
329
  route.keys.zip(values).each_with_object({}) do |(k, v), hash|
@@ -0,0 +1,172 @@
1
+ # lib/otto/core/static_mounts.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'rack/files'
6
+
7
+ class Otto
8
+ module Core
9
+ # Explicit static-file registration (issue #267).
10
+ #
11
+ # A static mount binds a URL prefix to one directory on disk:
12
+ #
13
+ # otto.mount_static('/assets', root: 'public/assets')
14
+ #
15
+ # Requests for GET /assets/<rest> are then resolved against
16
+ # public/assets/<rest> using the same containment policy as the implicit
17
+ # +public:+ directory (Otto::Core::FileSafety): the candidate is
18
+ # canonicalized with File.realpath and must land inside the mount's
19
+ # canonical root, be a regular readable file, and be owned by the process
20
+ # user or group. A mount never authorizes anything outside its own root,
21
+ # so several mounts can point into unrelated directories without exposing
22
+ # their parents or siblings.
23
+ #
24
+ # Registration is a boot-time operation. The root is canonicalized once,
25
+ # when the mount is registered, and every failure mode (missing,
26
+ # unreadable, not a directory, escaping symlink, malformed prefix,
27
+ # duplicate prefix) raises ArgumentError immediately so a misconfigured
28
+ # application does not start. Mounts participate in configuration
29
+ # freezing: mount_static raises FrozenError after freeze_configuration!,
30
+ # and the mount table is an immutable, sorted snapshot that dispatch reads
31
+ # without any per-request mutation.
32
+ #
33
+ # Dispatch precedence is fixed: literal routes, then static mounts (longest
34
+ # prefix first), then the implicit +public:+ directory, then dynamic
35
+ # routes. A mount claims files, not the prefix: when no mount root
36
+ # contains the requested file the request falls through to the next
37
+ # stage exactly as an unregistered path would. See Otto::Core::Router.
38
+ module StaticMounts
39
+ # One registered mount. +prefix+ is the normalized URL prefix ('' for a
40
+ # root mount), +root+ the canonical directory, +files+ the Rack::Files
41
+ # instance rooted there. Instances are frozen at construction.
42
+ StaticMount = Struct.new(:prefix, :root, :files) do
43
+ # Request-relative path under this mount, or nil when +path+ is not
44
+ # beneath the prefix. The prefix itself (the directory) never matches:
45
+ # a mount serves files, not directory listings.
46
+ #
47
+ # @param path [String] normalized dispatch path (leading '/')
48
+ # @return [String, nil]
49
+ def relative_path_for(path)
50
+ if prefix.empty?
51
+ path
52
+ elsif path.start_with?("#{prefix}/")
53
+ path[(prefix.length + 1)..]
54
+ end
55
+ end
56
+
57
+ # Prefix as an operator would write it ('/' for a root mount).
58
+ def display_prefix
59
+ prefix.empty? ? '/' : prefix
60
+ end
61
+ end
62
+
63
+ # Registered mounts, longest prefix first. Frozen snapshot; a new array
64
+ # replaces it on every registration so readers never observe a partial
65
+ # update.
66
+ #
67
+ # @return [Array<StaticMount>]
68
+ def static_mounts
69
+ @static_mounts
70
+ end
71
+
72
+ # Serve the files under +root+ at URLs beneath +prefix+.
73
+ #
74
+ # @param prefix [String] URL prefix starting with '/'. A trailing slash
75
+ # is ignored; '/' mounts the root at the top level.
76
+ # @param root [String] directory path; relative paths resolve against
77
+ # the process working directory and are canonicalized immediately.
78
+ # @return [StaticMount] the registered mount
79
+ # @raise [ArgumentError] on a malformed prefix, a duplicate prefix, or a
80
+ # root that is missing, unreadable, not a directory, not owned by the
81
+ # process user or group, or that cannot be canonicalized.
82
+ # @raise [FrozenError] after configuration freezing
83
+ def mount_static(prefix, root:)
84
+ ensure_not_frozen!
85
+
86
+ clean_prefix = normalize_mount_prefix(prefix)
87
+ if @static_mounts.any? { |mount| mount.prefix == clean_prefix }
88
+ raise ArgumentError,
89
+ "Static mount prefix #{display_mount_prefix(clean_prefix).inspect} is already registered"
90
+ end
91
+
92
+ canonical_root = canonicalize_mount_root(clean_prefix, root)
93
+ mount = StaticMount.new(clean_prefix, canonical_root, Rack::Files.new(canonical_root).freeze).freeze
94
+
95
+ # Longest prefix first so an overlay ('/assets/vendor') is consulted
96
+ # before the mount that contains it ('/assets'). Ties cannot happen:
97
+ # prefixes are unique. Rebuild rather than mutate so in-flight readers
98
+ # keep their snapshot.
99
+ @static_mounts = (@static_mounts + [mount]).sort_by { |m| -m.prefix.length }.freeze
100
+
101
+ Otto.structured_log(:debug, 'Static mount registered',
102
+ { prefix: mount.display_prefix, root: mount.root })
103
+ mount
104
+ end
105
+
106
+ private
107
+
108
+ # Resolve +path+ through the registered mounts, longest prefix first.
109
+ # Read-only: safe to call from concurrent request threads.
110
+ #
111
+ # @param path [String] normalized dispatch path (leading '/')
112
+ # @return [Array(StaticMount, Otto::Core::FileSafety::StaticFile), nil]
113
+ def resolve_mounted_file(path)
114
+ @static_mounts.each do |mount|
115
+ relative = mount.relative_path_for(path)
116
+ next if relative.nil?
117
+
118
+ static_file = resolve_file_under(mount.root, relative)
119
+ return [mount, static_file] if static_file
120
+ end
121
+ nil
122
+ end
123
+
124
+ # Validate and normalize a mount prefix. Returns '' for the root mount
125
+ # and a leading-slash, no-trailing-slash prefix otherwise, matching the
126
+ # normalized request path the router compares against.
127
+ def normalize_mount_prefix(prefix)
128
+ raise ArgumentError, "Static mount prefix must be a String, got #{prefix.class}" unless prefix.is_a?(String)
129
+ raise ArgumentError, "Static mount prefix #{prefix.inspect} contains a NUL byte" if prefix.include?("\0")
130
+ raise ArgumentError, "Static mount prefix #{prefix.inspect} must start with '/'" unless prefix.start_with?('/')
131
+
132
+ clean = prefix.sub(%r{/+\z}, '')
133
+ return '' if clean.empty?
134
+
135
+ segments = clean.split('/', -1).drop(1)
136
+ if segments.any? { |segment| segment.empty? || segment == '.' || segment == '..' }
137
+ raise ArgumentError,
138
+ "Static mount prefix #{prefix.inspect} must not contain empty, '.', or '..' segments"
139
+ end
140
+
141
+ clean.freeze
142
+ end
143
+
144
+ # Canonicalize a mount root under Otto's static-file safety policy and
145
+ # fail loudly on anything that could not be served safely.
146
+ def canonicalize_mount_root(prefix, root)
147
+ label = "Static mount #{display_mount_prefix(prefix).inspect}"
148
+ raise ArgumentError, "#{label} root must be a String, got #{root.class}" unless root.is_a?(String)
149
+ raise ArgumentError, "#{label} root must not be empty" if root.strip.empty?
150
+ raise ArgumentError, "#{label} root #{root.inspect} contains a NUL byte" if root.include?("\0")
151
+
152
+ real = safe_realpath(File.expand_path(root))
153
+ if real.nil?
154
+ raise ArgumentError,
155
+ "#{label} root #{root.inspect} cannot be resolved " \
156
+ '(missing, unreadable component, or symlink loop)'
157
+ end
158
+ raise ArgumentError, "#{label} root #{root.inspect} is not a directory" unless File.directory?(real)
159
+ raise ArgumentError, "#{label} root #{root.inspect} is not readable" unless File.readable?(real)
160
+
161
+ owned = File.owned?(real) || File.grpowned?(real)
162
+ raise ArgumentError, "#{label} root #{root.inspect} is not owned by the process user or group" unless owned
163
+
164
+ real.freeze
165
+ end
166
+
167
+ def display_mount_prefix(prefix)
168
+ prefix.empty? ? '/' : prefix
169
+ end
170
+ end
171
+ end
172
+ end
data/lib/otto/core.rb CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  require_relative 'core/router'
6
6
  require_relative 'core/file_safety'
7
+ require_relative 'core/static_mounts'
7
8
  require_relative 'core/configuration'
8
9
  require_relative 'core/error_handler'
9
10
  require_relative 'core/uri_generator'
data/lib/otto/static.rb CHANGED
@@ -15,6 +15,44 @@ class Otto
15
15
  [404, security_headers.merge({ 'content-type' => 'text/plain' }), ['Not Found']]
16
16
  end
17
17
 
18
+ # Return a per-request copy of a Rack triple so callers can never hand a
19
+ # shared object back to the Rack stack.
20
+ #
21
+ # Middleware above Otto (rack-session, Otto's own CSRF middleware, anything
22
+ # that calls +Rack::Utils.set_cookie_header!+) writes response headers in
23
+ # place. Returning a configured triple by reference lets those writes
24
+ # accumulate on the shared object for the life of the process, so every
25
+ # subsequent 404/500 replays every Set-Cookie any earlier one committed.
26
+ #
27
+ # The copy is intentionally shallow-plus-one: the headers container keeps
28
+ # its class (a +Rack::Headers+ stays case-insensitive), each Array-valued
29
+ # header (Rack 3's representation of a repeated header) is copied so an
30
+ # append cannot reach the shared Array, and an Array body is copied so a
31
+ # middleware appending chunks cannot grow the shared body. A frozen
32
+ # configured triple yields an unfrozen copy, so cookie middleware works
33
+ # after configuration freezing as well.
34
+ #
35
+ # @param response [Array] a Rack triple +[status, headers, body]+
36
+ # @return [Array] a new triple that shares no mutable container with +response+
37
+ def copy_response(response)
38
+ status, headers, body = response
39
+ [status, copy_headers(headers), body.is_a?(Array) ? body.dup : body]
40
+ end
41
+
42
+ # Copy a Rack headers container, keeping its class and copying Array values.
43
+ #
44
+ # @param headers [Hash, Rack::Headers, nil] the headers to copy
45
+ # @return [Hash, Rack::Headers] a new container of the same class
46
+ def copy_headers(headers)
47
+ return {} if headers.nil?
48
+
49
+ copied = headers.dup
50
+ copied.each_pair do |key, value|
51
+ copied[key] = value.dup if value.is_a?(Array)
52
+ end
53
+ copied
54
+ end
55
+
18
56
  def security_headers
19
57
  {
20
58
  'x-frame-options' => 'DENY',
data/lib/otto/version.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.10.0'
6
+ VERSION = '2.11.0'
7
7
  end
data/lib/otto.rb CHANGED
@@ -55,6 +55,7 @@ require_relative 'otto/logging_helpers'
55
55
  class Otto
56
56
  include Otto::Core::Router
57
57
  include Otto::Core::FileSafety
58
+ include Otto::Core::StaticMounts
58
59
  include Otto::Core::Configuration
59
60
  include Otto::Core::ErrorHandler
60
61
  include Otto::Core::UriGenerator
@@ -68,6 +69,10 @@ class Otto
68
69
 
69
70
  LIB_HOME = __dir__ unless defined?(Otto::LIB_HOME)
70
71
 
72
+ # Parameter types (from Proc#parameters / Method#parameters) that consume
73
+ # one positional argument each. See {#fallback_call_args}.
74
+ POSITIONAL_PARAMETER_TYPES = %i[req opt].freeze
75
+
71
76
  @debug = case ENV.fetch('OTTO_DEBUG', nil)
72
77
  in 'true' | '1' | 'yes' | 'on'
73
78
  true
@@ -80,8 +85,54 @@ class Otto
80
85
  :routes_by_definition, :option,
81
86
  :static_route, :security_config, :locale_config, :auth_config,
82
87
  :route_handler_factory, :mcp_server, :caddy_tls_server, :security, :middleware,
83
- :error_handlers, :request_class, :response_class
84
- attr_accessor :not_found, :server_error
88
+ :error_handlers, :request_class, :response_class,
89
+ :not_found, :server_error
90
+
91
+ # Configure the response returned when no route (and no +/404+ route) matches.
92
+ #
93
+ # Accepts either a Rack triple +[status, headers, body]+ (the body must
94
+ # respond to +each+, or +call+ for a streaming body) or anything that
95
+ # responds to +call(env)+ and returns one. A static triple is never handed
96
+ # back to the Rack stack by reference: Otto returns a per-request copy whose
97
+ # headers (and Array-valued header entries, and Array body) are fresh
98
+ # containers, so cookie middleware such as rack-session or Otto's own CSRF
99
+ # middleware cannot accumulate +Set-Cookie+ values on the configured object
100
+ # and replay them to later clients. Prefer the callable form when the
101
+ # response should vary per request.
102
+ #
103
+ # @param response [Array, #call, nil] a Rack triple, a callable, or nil to
104
+ # restore the built-in {Otto::Static.not_found} response
105
+ # @raise [ArgumentError] when +response+ is neither a Rack triple nor callable
106
+ #
107
+ # @example Static triple (copied per request)
108
+ # otto.not_found = [404, { 'content-type' => 'application/json' }, ['{"error":"Not Found"}']]
109
+ #
110
+ # @example Callable, built fresh on every miss
111
+ # otto.not_found = ->(env) { [404, { 'content-type' => 'text/plain' }, ["No #{env['PATH_INFO']}"]] }
112
+ def not_found=(response)
113
+ @not_found = validate_fallback_response!(:not_found, response)
114
+ end
115
+
116
+ # Configure the response returned for an unhandled error when no +/500+
117
+ # route is configured and the client does not prefer JSON.
118
+ #
119
+ # Accepts either a Rack triple or anything that responds to +call(env, error)+
120
+ # and returns one; a callable declaring a single positional parameter (or
121
+ # none) receives only what it declares. +env['otto.error_id']+ carries the
122
+ # logged correlation id. As with {#not_found=}, a static triple is copied
123
+ # per request so header writes by cookie middleware never touch the
124
+ # configured object. A callable that raises is logged and replaced by the
125
+ # built-in secure error response.
126
+ #
127
+ # @param response [Array, #call, nil] a Rack triple, a callable, or nil to
128
+ # restore the built-in secure error response
129
+ # @raise [ArgumentError] when +response+ is neither a Rack triple nor callable
130
+ #
131
+ # @example Callable receiving the error
132
+ # otto.server_error = ->(env, error) { [500, { 'content-type' => 'text/plain' }, ['Oops']] }
133
+ def server_error=(response)
134
+ @server_error = validate_fallback_response!(:server_error, response)
135
+ end
85
136
 
86
137
  def initialize(path = nil, opts = {})
87
138
  constructed = false
@@ -188,6 +239,10 @@ class Otto
188
239
  # so reverse lookups (Otto#uri) consult this index instead of the
189
240
  # single-route @route_definitions entry (issue #190).
190
241
  @routes_by_definition = {}
242
+ # Explicit static mounts (Core::StaticMounts#mount_static). Always a
243
+ # frozen snapshot, replaced wholesale on registration; dispatch reads it
244
+ # without locking.
245
+ @static_mounts = [].freeze
191
246
  @security_config = Otto::Security::Config.new
192
247
  @middleware = Otto::Core::MiddlewareStack.new
193
248
  # Initialize @auth_config first so it can be shared with the configurator
@@ -301,6 +356,70 @@ class Otto
301
356
  end
302
357
  end
303
358
 
359
+ # Validate a value assigned to {#not_found=} or {#server_error=}.
360
+ #
361
+ # @param name [Symbol] the setting name, for the error message
362
+ # @param response [Object] the assigned value
363
+ # @return [Array, #call, nil] the value, when acceptable
364
+ # @raise [ArgumentError] otherwise
365
+ def validate_fallback_response!(name, response)
366
+ return response if response.nil? || response.respond_to?(:call)
367
+ return response if rack_triple?(response)
368
+
369
+ raise ArgumentError,
370
+ "#{name} must be a Rack triple [status, headers, body] or respond to #call, got #{response.inspect}"
371
+ end
372
+
373
+ # A Rack triple: an Integer-like status, Hash-like headers, and a body that
374
+ # responds to +each+ (or +call+, for a streaming body).
375
+ def rack_triple?(response)
376
+ return false unless response.is_a?(Array) && response.length == 3
377
+
378
+ status, headers, body = response
379
+ status.respond_to?(:to_int) && headers.respond_to?(:each_pair) &&
380
+ (body.respond_to?(:each) || body.respond_to?(:call))
381
+ end
382
+
383
+ # Resolve a configured fallback into a fresh Rack triple for one request.
384
+ #
385
+ # A callable is invoked with as many of +args+ as it accepts (see
386
+ # {#fallback_call_args}); a static triple is used as-is. Either way the
387
+ # result is copied (see {Otto::Static.copy_response}) so the Rack stack
388
+ # never receives a container shared with the configuration or with another
389
+ # request.
390
+ #
391
+ # @param name [Symbol] the setting name, for the error message
392
+ # @param fallback [Array, #call] the configured value
393
+ # @param args [Array] positional arguments offered to a callable fallback
394
+ # @return [Array] a new Rack triple
395
+ # @raise [TypeError] when a callable returns something other than a Rack triple
396
+ def resolve_fallback_response(name, fallback, *args)
397
+ response = fallback.respond_to?(:call) ? fallback.call(*fallback_call_args(fallback, args)) : fallback
398
+ unless rack_triple?(response)
399
+ raise TypeError,
400
+ "#{name} callable must return a Rack triple [status, headers, body], got #{response.inspect}"
401
+ end
402
+
403
+ Otto::Static.copy_response(response)
404
+ end
405
+
406
+ # Trim +args+ to the positional parameters +callable+ declares, so a lambda
407
+ # or Method that takes fewer (or optional) parameters is never handed an
408
+ # argument it would reject. A splat parameter receives everything. Arity
409
+ # alone cannot express this: +->(env = nil) {}+ has arity -1, the same as
410
+ # +proc { |*a| }+, yet accepts at most one argument.
411
+ #
412
+ # @param callable [#call]
413
+ # @param args [Array] the arguments on offer, in order
414
+ # @return [Array] the leading subset of +args+ the callable accepts
415
+ def fallback_call_args(callable, args)
416
+ params = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
417
+ return args if params.any? { |type, _name| type == :rest }
418
+
419
+ args.first(params.count { |type, _name| POSITIONAL_PARAMETER_TYPES.include?(type) })
420
+ end
421
+ private :validate_fallback_response!, :rack_triple?, :resolve_fallback_response, :fallback_call_args
422
+
304
423
  # Class methods for Otto framework providing singleton access and configuration
305
424
  module ClassMethods
306
425
  def default
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: otto
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.10.0
4
+ version: 2.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-05 00:00:00.000000000 Z
11
+ date: 2026-09-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -223,6 +223,7 @@ files:
223
223
  - lib/otto/core/middleware_management.rb
224
224
  - lib/otto/core/middleware_stack.rb
225
225
  - lib/otto/core/router.rb
226
+ - lib/otto/core/static_mounts.rb
226
227
  - lib/otto/core/uri_generator.rb
227
228
  - lib/otto/design_system.rb
228
229
  - lib/otto/env_keys.rb