otto 2.0.0.pre1 → 2.0.0.pre3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +2 -3
- data/.github/workflows/claude-code-review.yml +30 -14
- data/.github/workflows/claude.yml +1 -1
- data/.rubocop.yml +4 -1
- data/CHANGELOG.rst +54 -6
- data/CLAUDE.md +537 -0
- data/Gemfile +3 -2
- data/Gemfile.lock +34 -26
- data/benchmark_middleware_wrap.rb +163 -0
- data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +36 -0
- data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +5 -0
- data/docs/.gitignore +2 -0
- data/docs/ipaddr-encoding-quirk.md +34 -0
- data/docs/migrating/v2.0.0-pre2.md +338 -0
- data/examples/authentication_strategies/config.ru +0 -1
- data/lib/otto/core/configuration.rb +91 -41
- data/lib/otto/core/freezable.rb +93 -0
- data/lib/otto/core/middleware_stack.rb +103 -16
- data/lib/otto/core/router.rb +8 -7
- data/lib/otto/core.rb +8 -0
- data/lib/otto/env_keys.rb +118 -0
- data/lib/otto/helpers/base.rb +2 -21
- data/lib/otto/helpers/request.rb +80 -2
- data/lib/otto/helpers/response.rb +25 -3
- data/lib/otto/helpers.rb +4 -0
- data/lib/otto/locale/config.rb +56 -0
- data/lib/otto/mcp/{validation.rb → schema_validation.rb} +3 -2
- data/lib/otto/mcp/server.rb +26 -13
- data/lib/otto/mcp.rb +3 -0
- data/lib/otto/privacy/config.rb +199 -0
- data/lib/otto/privacy/geo_resolver.rb +115 -0
- data/lib/otto/privacy/ip_privacy.rb +175 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +136 -0
- data/lib/otto/privacy.rb +29 -0
- data/lib/otto/response_handlers/json.rb +6 -0
- data/lib/otto/route.rb +44 -48
- data/lib/otto/route_handlers/base.rb +1 -2
- data/lib/otto/route_handlers/factory.rb +24 -9
- data/lib/otto/route_handlers/logic_class.rb +2 -2
- data/lib/otto/security/authentication/auth_failure.rb +44 -0
- data/lib/otto/security/authentication/auth_strategy.rb +3 -3
- data/lib/otto/security/authentication/route_auth_wrapper.rb +260 -0
- data/lib/otto/security/authentication/strategies/{public_strategy.rb → noauth_strategy.rb} +6 -2
- data/lib/otto/security/authentication/strategy_result.rb +129 -15
- data/lib/otto/security/authentication.rb +5 -6
- data/lib/otto/security/config.rb +51 -18
- data/lib/otto/security/configurator.rb +2 -15
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +211 -0
- data/lib/otto/security/middleware/rate_limit_middleware.rb +19 -3
- data/lib/otto/security.rb +9 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +183 -89
- data/otto.gemspec +5 -0
- metadata +83 -8
- data/changelog.d/20250911_235619_delano_next.rst +0 -28
- data/changelog.d/20250912_123055_delano_remove_ostruct.rst +0 -21
- data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +0 -21
- data/lib/otto/security/authentication/authentication_middleware.rb +0 -123
- data/lib/otto/security/authentication/failure_result.rb +0 -36
data/Gemfile.lock
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
otto (2.0.0.
|
|
4
|
+
otto (2.0.0.pre3)
|
|
5
|
+
concurrent-ruby (~> 1.3, < 2.0)
|
|
5
6
|
facets (~> 3.1)
|
|
7
|
+
ipaddr (~> 1, < 2.0)
|
|
8
|
+
logger (~> 1, < 2.0)
|
|
6
9
|
loofah (~> 2.20)
|
|
7
10
|
rack (~> 3.1, < 4.0)
|
|
8
11
|
rack-parser (~> 0.7)
|
|
@@ -12,6 +15,7 @@ GEM
|
|
|
12
15
|
remote: https://rubygems.org/
|
|
13
16
|
specs:
|
|
14
17
|
ast (2.4.3)
|
|
18
|
+
benchmark (0.4.1)
|
|
15
19
|
bigdecimal (3.2.3)
|
|
16
20
|
concurrent-ruby (1.3.5)
|
|
17
21
|
crass (1.0.6)
|
|
@@ -20,15 +24,16 @@ GEM
|
|
|
20
24
|
irb (~> 1.10)
|
|
21
25
|
reline (>= 0.3.8)
|
|
22
26
|
diff-lcs (1.6.2)
|
|
23
|
-
erb (5.
|
|
27
|
+
erb (5.1.1)
|
|
24
28
|
facets (3.1.0)
|
|
25
29
|
hana (1.3.7)
|
|
26
30
|
io-console (0.8.1)
|
|
31
|
+
ipaddr (1.2.7)
|
|
27
32
|
irb (1.15.2)
|
|
28
33
|
pp (>= 0.6.0)
|
|
29
34
|
rdoc (>= 4.0.0)
|
|
30
35
|
reline (>= 0.4.2)
|
|
31
|
-
json (2.
|
|
36
|
+
json (2.15.1)
|
|
32
37
|
json_schemer (2.4.0)
|
|
33
38
|
bigdecimal
|
|
34
39
|
hana (~> 1.3)
|
|
@@ -40,22 +45,22 @@ GEM
|
|
|
40
45
|
loofah (2.24.1)
|
|
41
46
|
crass (~> 1.0.2)
|
|
42
47
|
nokogiri (>= 1.12.0)
|
|
43
|
-
minitest (5.
|
|
44
|
-
nokogiri (1.18.
|
|
48
|
+
minitest (5.26.0)
|
|
49
|
+
nokogiri (1.18.10-aarch64-linux-gnu)
|
|
45
50
|
racc (~> 1.4)
|
|
46
|
-
nokogiri (1.18.
|
|
51
|
+
nokogiri (1.18.10-aarch64-linux-musl)
|
|
47
52
|
racc (~> 1.4)
|
|
48
|
-
nokogiri (1.18.
|
|
53
|
+
nokogiri (1.18.10-arm-linux-gnu)
|
|
49
54
|
racc (~> 1.4)
|
|
50
|
-
nokogiri (1.18.
|
|
55
|
+
nokogiri (1.18.10-arm-linux-musl)
|
|
51
56
|
racc (~> 1.4)
|
|
52
|
-
nokogiri (1.18.
|
|
57
|
+
nokogiri (1.18.10-arm64-darwin)
|
|
53
58
|
racc (~> 1.4)
|
|
54
|
-
nokogiri (1.18.
|
|
59
|
+
nokogiri (1.18.10-x86_64-darwin)
|
|
55
60
|
racc (~> 1.4)
|
|
56
|
-
nokogiri (1.18.
|
|
61
|
+
nokogiri (1.18.10-x86_64-linux-gnu)
|
|
57
62
|
racc (~> 1.4)
|
|
58
|
-
nokogiri (1.18.
|
|
63
|
+
nokogiri (1.18.10-x86_64-linux-musl)
|
|
59
64
|
racc (~> 1.4)
|
|
60
65
|
parallel (1.27.0)
|
|
61
66
|
parser (3.3.9.0)
|
|
@@ -63,16 +68,16 @@ GEM
|
|
|
63
68
|
racc
|
|
64
69
|
pastel (0.8.0)
|
|
65
70
|
tty-color (~> 0.5)
|
|
66
|
-
pp (0.6.
|
|
71
|
+
pp (0.6.3)
|
|
67
72
|
prettyprint
|
|
68
73
|
prettier_print (1.2.1)
|
|
69
74
|
prettyprint (0.2.0)
|
|
70
|
-
prism (1.
|
|
75
|
+
prism (1.5.2)
|
|
71
76
|
psych (5.2.6)
|
|
72
77
|
date
|
|
73
78
|
stringio
|
|
74
79
|
racc (1.8.1)
|
|
75
|
-
rack (3.2.
|
|
80
|
+
rack (3.2.3)
|
|
76
81
|
rack-attack (6.7.0)
|
|
77
82
|
rack (>= 1.0, < 4)
|
|
78
83
|
rack-parser (0.7.0)
|
|
@@ -84,13 +89,14 @@ GEM
|
|
|
84
89
|
rainbow (3.1.1)
|
|
85
90
|
rbs (3.9.5)
|
|
86
91
|
logger
|
|
87
|
-
rdoc (6.
|
|
92
|
+
rdoc (6.15.0)
|
|
88
93
|
erb
|
|
89
94
|
psych (>= 4.0.0)
|
|
90
|
-
|
|
95
|
+
tsort
|
|
96
|
+
regexp_parser (2.11.3)
|
|
91
97
|
reline (0.6.2)
|
|
92
98
|
io-console (~> 0.5)
|
|
93
|
-
rexml (3.4.
|
|
99
|
+
rexml (3.4.4)
|
|
94
100
|
rspec (3.13.1)
|
|
95
101
|
rspec-core (~> 3.13.0)
|
|
96
102
|
rspec-expectations (~> 3.13.0)
|
|
@@ -103,8 +109,8 @@ GEM
|
|
|
103
109
|
rspec-mocks (3.13.5)
|
|
104
110
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
105
111
|
rspec-support (~> 3.13.0)
|
|
106
|
-
rspec-support (3.13.
|
|
107
|
-
rubocop (1.
|
|
112
|
+
rspec-support (3.13.6)
|
|
113
|
+
rubocop (1.81.1)
|
|
108
114
|
json (~> 2.3)
|
|
109
115
|
language_server-protocol (~> 3.17.0.2)
|
|
110
116
|
lint_roller (~> 1.1.0)
|
|
@@ -112,10 +118,10 @@ GEM
|
|
|
112
118
|
parser (>= 3.3.0.2)
|
|
113
119
|
rainbow (>= 2.2.2, < 4.0)
|
|
114
120
|
regexp_parser (>= 2.9.3, < 3.0)
|
|
115
|
-
rubocop-ast (>= 1.
|
|
121
|
+
rubocop-ast (>= 1.47.1, < 2.0)
|
|
116
122
|
ruby-progressbar (~> 1.7)
|
|
117
123
|
unicode-display_width (>= 2.4.0, < 4.0)
|
|
118
|
-
rubocop-ast (1.
|
|
124
|
+
rubocop-ast (1.47.1)
|
|
119
125
|
parser (>= 3.3.7.2)
|
|
120
126
|
prism (~> 1.4)
|
|
121
127
|
rubocop-performance (1.26.0)
|
|
@@ -139,15 +145,16 @@ GEM
|
|
|
139
145
|
stringio (3.1.7)
|
|
140
146
|
syntax_tree (6.3.0)
|
|
141
147
|
prettier_print (>= 1.2.0)
|
|
142
|
-
tryouts (3.6.
|
|
148
|
+
tryouts (3.6.1)
|
|
143
149
|
concurrent-ruby (~> 1.0)
|
|
144
150
|
irb
|
|
145
151
|
minitest (~> 5.0)
|
|
146
152
|
pastel (~> 0.8)
|
|
147
153
|
prism (~> 1.0)
|
|
148
|
-
rspec (
|
|
154
|
+
rspec (>= 3.0, < 5.0)
|
|
149
155
|
tty-cursor (~> 0.7)
|
|
150
156
|
tty-screen (~> 0.8)
|
|
157
|
+
tsort (0.2.0)
|
|
151
158
|
tty-color (0.6.0)
|
|
152
159
|
tty-cursor (0.7.1)
|
|
153
160
|
tty-screen (0.8.2)
|
|
@@ -166,6 +173,7 @@ PLATFORMS
|
|
|
166
173
|
x86_64-linux-musl
|
|
167
174
|
|
|
168
175
|
DEPENDENCIES
|
|
176
|
+
benchmark
|
|
169
177
|
debug
|
|
170
178
|
json_schemer
|
|
171
179
|
otto!
|
|
@@ -173,14 +181,14 @@ DEPENDENCIES
|
|
|
173
181
|
rack-test
|
|
174
182
|
rackup
|
|
175
183
|
rspec (~> 3.13)
|
|
176
|
-
rubocop
|
|
184
|
+
rubocop (~> 1.81.1)
|
|
177
185
|
rubocop-performance
|
|
178
186
|
rubocop-rspec
|
|
179
187
|
rubocop-thread_safety
|
|
180
188
|
ruby-lsp
|
|
181
189
|
stackprof
|
|
182
190
|
syntax_tree
|
|
183
|
-
tryouts (~> 3.6.
|
|
191
|
+
tryouts (~> 3.6.1)
|
|
184
192
|
|
|
185
193
|
BUNDLED WITH
|
|
186
194
|
2.7.1
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Benchmark to measure real-world Otto performance with actual routes and middleware
|
|
5
|
+
# Usage: ruby benchmark_middleware_wrap.rb
|
|
6
|
+
|
|
7
|
+
require 'bundler/setup'
|
|
8
|
+
require 'benchmark'
|
|
9
|
+
require 'stringio'
|
|
10
|
+
require 'tempfile'
|
|
11
|
+
|
|
12
|
+
require_relative 'lib/otto'
|
|
13
|
+
|
|
14
|
+
REQUEST_COUNT = 50_000
|
|
15
|
+
MIDDLEWARE_COUNT = 40
|
|
16
|
+
|
|
17
|
+
# Create a temporary routes file
|
|
18
|
+
routes_content = <<~ROUTES
|
|
19
|
+
GET / TestApp.index
|
|
20
|
+
GET /users/:id TestApp.show
|
|
21
|
+
POST /users TestApp.create
|
|
22
|
+
GET /health TestApp.health
|
|
23
|
+
ROUTES
|
|
24
|
+
|
|
25
|
+
routes_file = Tempfile.new(['routes', '.txt'])
|
|
26
|
+
routes_file.write(routes_content)
|
|
27
|
+
routes_file.close
|
|
28
|
+
|
|
29
|
+
# Define test application
|
|
30
|
+
class TestApp
|
|
31
|
+
def self.index(_env, _params = {})
|
|
32
|
+
[200, { 'Content-Type' => 'text/html' }, ['Welcome']]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.show(env, params = {})
|
|
36
|
+
user_id = params[:id] || env.dig('otto.params', :id) || '123'
|
|
37
|
+
[200, { 'Content-Type' => 'text/html' }, ["User #{user_id}"]]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.create(_env, _params = {})
|
|
41
|
+
[201, { 'Content-Type' => 'application/json' }, ['{"id": 123}']]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.health(_env, _params = {})
|
|
45
|
+
[200, { 'Content-Type' => 'text/plain' }, ['OK']]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Create real Rack middleware
|
|
50
|
+
class BenchmarkMiddleware
|
|
51
|
+
def initialize(app, _config = nil)
|
|
52
|
+
@app = app
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def call(env)
|
|
56
|
+
@app.call(env)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Create Otto instance with real configuration
|
|
61
|
+
otto = Otto.new(routes_file.path)
|
|
62
|
+
|
|
63
|
+
# Add real Otto security middleware
|
|
64
|
+
otto.enable_csrf_protection!
|
|
65
|
+
otto.enable_request_validation!
|
|
66
|
+
|
|
67
|
+
# Add custom middleware to reach target count
|
|
68
|
+
current_count = otto.middleware.size
|
|
69
|
+
(MIDDLEWARE_COUNT - current_count).times do
|
|
70
|
+
otto.use(Class.new(BenchmarkMiddleware))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Suppress error logging for benchmark
|
|
74
|
+
Otto.logger.level = Logger::FATAL
|
|
75
|
+
|
|
76
|
+
puts "\n" + ("=" * 70)
|
|
77
|
+
puts "Otto Performance Benchmark"
|
|
78
|
+
puts ("=" * 70)
|
|
79
|
+
puts "Configuration:"
|
|
80
|
+
puts " Routes: #{otto.instance_variable_get(:@route_definitions).size}"
|
|
81
|
+
actual_app = otto.instance_variable_get(:@app)
|
|
82
|
+
puts " Middleware: #{otto.middleware.size} (#{MIDDLEWARE_COUNT} total in stack, app built: #{!actual_app.nil?})"
|
|
83
|
+
puts " Requests: #{REQUEST_COUNT.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}"
|
|
84
|
+
puts ("=" * 70)
|
|
85
|
+
|
|
86
|
+
# Create realistic Rack environments for different routes
|
|
87
|
+
def make_env(method, path)
|
|
88
|
+
{
|
|
89
|
+
'REQUEST_METHOD' => method,
|
|
90
|
+
'PATH_INFO' => path,
|
|
91
|
+
'QUERY_STRING' => '',
|
|
92
|
+
'SERVER_NAME' => 'example.com',
|
|
93
|
+
'SERVER_PORT' => '80',
|
|
94
|
+
'rack.version' => [1, 3],
|
|
95
|
+
'rack.url_scheme' => 'http',
|
|
96
|
+
'rack.input' => StringIO.new,
|
|
97
|
+
'rack.errors' => StringIO.new,
|
|
98
|
+
'rack.multithread' => false,
|
|
99
|
+
'rack.multiprocess' => true,
|
|
100
|
+
'rack.run_once' => false,
|
|
101
|
+
'REMOTE_ADDR' => '192.168.1.100',
|
|
102
|
+
'HTTP_USER_AGENT' => 'Benchmark/1.0',
|
|
103
|
+
'rack.session' => {}
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Test different routes
|
|
108
|
+
routes = [
|
|
109
|
+
['GET', '/'],
|
|
110
|
+
['GET', '/users/123'],
|
|
111
|
+
['POST', '/users'],
|
|
112
|
+
['GET', '/health']
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
# Warmup
|
|
116
|
+
puts "\nWarming up (1,000 requests)..."
|
|
117
|
+
1_000.times do |i|
|
|
118
|
+
method, path = routes[i % routes.size]
|
|
119
|
+
env = make_env(method, path)
|
|
120
|
+
otto.call(env)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
puts "\n" + ("=" * 70)
|
|
124
|
+
puts "Running benchmark..."
|
|
125
|
+
puts ("=" * 70)
|
|
126
|
+
|
|
127
|
+
# Benchmark
|
|
128
|
+
result = Benchmark.measure do
|
|
129
|
+
REQUEST_COUNT.times do |i|
|
|
130
|
+
method, path = routes[i % routes.size]
|
|
131
|
+
env = make_env(method, path)
|
|
132
|
+
otto.call(env)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
total_time = result.real
|
|
137
|
+
per_request = (total_time / REQUEST_COUNT * 1_000_000).round(2)
|
|
138
|
+
requests_per_sec = (REQUEST_COUNT / total_time).round(0)
|
|
139
|
+
|
|
140
|
+
puts "\nResults:"
|
|
141
|
+
puts ("=" * 70)
|
|
142
|
+
puts " Total time: #{(total_time * 1000).round(2)}ms"
|
|
143
|
+
puts " Time per request: #{per_request}µs"
|
|
144
|
+
puts " Requests/sec: #{requests_per_sec.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}"
|
|
145
|
+
puts ("=" * 70)
|
|
146
|
+
|
|
147
|
+
# Performance analysis
|
|
148
|
+
puts "\nPerformance Analysis:"
|
|
149
|
+
if per_request < 20
|
|
150
|
+
puts " ✓ Excellent performance (< 20µs per request)"
|
|
151
|
+
elsif per_request < 50
|
|
152
|
+
puts " ✓ Good performance (< 50µs per request)"
|
|
153
|
+
elsif per_request < 100
|
|
154
|
+
puts " ~ Acceptable performance (< 100µs per request)"
|
|
155
|
+
else
|
|
156
|
+
puts " ⚠ May need optimization (#{per_request}µs per request)"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
puts "\nMiddleware overhead: ~#{((per_request - 2.5) / MIDDLEWARE_COUNT).round(3)}µs per middleware"
|
|
160
|
+
puts
|
|
161
|
+
|
|
162
|
+
# Cleanup
|
|
163
|
+
routes_file.unlink
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Changed
|
|
2
|
+
-------
|
|
3
|
+
|
|
4
|
+
- Authentication now handled by RouteAuthWrapper at handler level instead of middleware
|
|
5
|
+
- RouteAuthWrapper enhanced with session persistence, security headers, strategy caching, and sophisticated pattern matching
|
|
6
|
+
- env['otto.strategy_result'] now GUARANTEED to be present on all routes (authenticated or anonymous)
|
|
7
|
+
- RouteAuthWrapper now wraps all route handlers, not just routes with auth requirements
|
|
8
|
+
|
|
9
|
+
Removed
|
|
10
|
+
-------
|
|
11
|
+
|
|
12
|
+
- Removed AuthenticationMiddleware (architecturally broken - executed before routing)
|
|
13
|
+
- Removed enable_authentication! (no longer needed - RouteAuthWrapper handles auth automatically)
|
|
14
|
+
- Removed defensive nil fallback from LogicClassHandler (no longer needed)
|
|
15
|
+
|
|
16
|
+
Fixed
|
|
17
|
+
-----
|
|
18
|
+
|
|
19
|
+
- Session persistence now works correctly (env['rack.session'] references same object as strategy_result.session)
|
|
20
|
+
- Security headers now included on all authentication failure responses (401/302)
|
|
21
|
+
- Strategy lookups now cached for performance
|
|
22
|
+
- env['otto.strategy_result'] is now guaranteed to be present (anonymous StrategyResult for public routes)
|
|
23
|
+
- Routes without auth requirements now get anonymous StrategyResult with IP metadata
|
|
24
|
+
|
|
25
|
+
Security
|
|
26
|
+
--------
|
|
27
|
+
|
|
28
|
+
- Authentication strategies now execute after routing when route_definition is available
|
|
29
|
+
- Supports exact match, prefix match (role:admin), and fallback patterns for strategies
|
|
30
|
+
|
|
31
|
+
Documentation
|
|
32
|
+
-------------
|
|
33
|
+
|
|
34
|
+
- Updated CLAUDE.md with RouteAuthWrapper architecture overview
|
|
35
|
+
- Updated env_keys.rb to document guaranteed presence of strategy_result
|
|
36
|
+
- Added comprehensive tests for anonymous route handling
|
data/docs/.gitignore
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# IPAddr#to_s Encoding Quirk in Ruby 3
|
|
2
|
+
|
|
3
|
+
Ruby's `IPAddr#to_s` returns inconsistent encodings: IPv4 addresses use US-ASCII, IPv6 addresses use UTF-8.
|
|
4
|
+
|
|
5
|
+
## Behavior
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
IPAddr.new('192.168.1.1').to_s.encoding # => #<Encoding:US-ASCII>
|
|
9
|
+
IPAddr.new('::1').to_s.encoding # => #<Encoding:UTF-8>
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Cause
|
|
13
|
+
|
|
14
|
+
Different string construction in IPAddr's `_to_string` method:
|
|
15
|
+
|
|
16
|
+
- **IPv4**: `Array#join('.')` → US-ASCII optimization
|
|
17
|
+
- **IPv6**: `String#%` → UTF-8 default
|
|
18
|
+
|
|
19
|
+
## Impact
|
|
20
|
+
|
|
21
|
+
- Rack expects UTF-8 strings
|
|
22
|
+
- Mixed encodings cause `Encoding::CompatibilityError`
|
|
23
|
+
- String operations fail on encoding mismatches
|
|
24
|
+
|
|
25
|
+
## Solution
|
|
26
|
+
|
|
27
|
+
Use `force_encoding('UTF-8')` instead of `encode('UTF-8')`:
|
|
28
|
+
|
|
29
|
+
- IP addresses contain only ASCII characters
|
|
30
|
+
- ASCII bytes are identical in US-ASCII and UTF-8
|
|
31
|
+
- `force_encoding` changes label only (O(1))
|
|
32
|
+
- `encode` creates new string (O(n))
|
|
33
|
+
|
|
34
|
+
This ensures consistent UTF-8 encoding across all IP strings.
|