ruby_routes 1.0.0 → 2.0.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: 02d39aae113654256dae0905438ac98a540854a5d131dea6c4b2b606b51bf4ba
4
- data.tar.gz: ba01a85fc21713387e76c183a41e786106f1a053e2485d294895ac36027d94a5
3
+ metadata.gz: 4017abab11dd0945bcfe1659b2ed1456c2cf82a8993db55d094d283c48c8ebd0
4
+ data.tar.gz: 07f0aba728bf81cdfdc245e20a212f1775b2314a826c88f0e25b8ff2b608c195
5
5
  SHA512:
6
- metadata.gz: c3b230ef35186d4f96714ba708b933cf7897166a74b7162949a3144aa21dc79aab2446a0b14d1e0504ea30f941146a7e0f1841d5908e6a6175d771a9b4a658ad
7
- data.tar.gz: 9d476bdbaf46cef516cd51adb218097137863fb12976e98d87a7c8d7f77e3609421d81ba14ed77d67cd59054d4356690d088b619e8e089964cd4fdd6055225ac
6
+ metadata.gz: a4aa77ba441b9e87cdcb31cae5b5f12babaf93396e400723d5f845392ac132520c4398e19d49d0755e05fc305791b54a3676a460ba8c73cd4b5ee55647fb2589
7
+ data.tar.gz: a3b883c253731dfad5eaf34a28fb89684d0f9fa921127f6e428225743a93d87934508e49f59c3710964cc8a444b729f9278872aa8d991c4302abbb7ff8645b66
data/README.md CHANGED
@@ -8,7 +8,7 @@ A lightweight, flexible routing system for Ruby that provides a Rails-like DSL f
8
8
  - **HTTP Method Support**: GET, POST, PUT, PATCH, DELETE, and custom methods
9
9
  - **RESTful Resources**: Automatic generation of RESTful routes
10
10
  - **Nested Routes**: Support for nested resources and namespaces
11
- - **Route Constraints**: Add constraints to routes (regex, etc.)
11
+ - **Secure Route Constraints**: Powerful constraint system with built-in security ([see CONSTRAINTS.md](CONSTRAINTS.md))
12
12
  - **Named Routes**: Generate URLs from route names
13
13
  - **Path Generation**: Build URLs with parameters
14
14
  - **Scope Support**: Group routes with common options
@@ -62,7 +62,6 @@ end
62
62
  ```
63
63
 
64
64
  This creates the following routes:
65
-
66
65
  - `GET /users` → `users#index`
67
66
  - `GET /users/new` → `users#new`
68
67
  - `POST /users` → `users#create`
@@ -117,14 +116,91 @@ end
117
116
  # etc.
118
117
  ```
119
118
 
120
- ### Scopes and Constraints
119
+ ### Route Constraints
120
+
121
+ Ruby Routes provides a powerful and secure constraint system to validate route parameters. **For security reasons, Proc constraints are deprecated** - use the secure alternatives below.
122
+
123
+ #### Built-in Constraint Types
121
124
 
122
125
  ```ruby
123
126
  router = RubyRoutes.draw do
124
- scope constraints: { id: /\d+/ } do
125
- get '/users/:id', to: 'users#show'
126
- end
127
+ # Integer validation
128
+ get '/users/:id', to: 'users#show', constraints: { id: :int }
129
+
130
+ # UUID validation
131
+ get '/resources/:uuid', to: 'resources#show', constraints: { uuid: :uuid }
127
132
 
133
+ # Email validation
134
+ get '/users/:email', to: 'users#show', constraints: { email: :email }
135
+
136
+ # URL-friendly slug validation
137
+ get '/posts/:slug', to: 'posts#show', constraints: { slug: :slug }
138
+
139
+ # Alphabetic characters only
140
+ get '/categories/:name', to: 'categories#show', constraints: { name: :alpha }
141
+
142
+ # Alphanumeric characters only
143
+ get '/codes/:code', to: 'codes#show', constraints: { code: :alphanumeric }
144
+ end
145
+ ```
146
+
147
+ #### Hash-based Constraints (Recommended)
148
+
149
+ ```ruby
150
+ router = RubyRoutes.draw do
151
+ # Length constraints
152
+ get '/users/:username', to: 'users#show',
153
+ constraints: {
154
+ username: {
155
+ min_length: 3,
156
+ max_length: 20,
157
+ format: /\A[a-zA-Z0-9_]+\z/
158
+ }
159
+ }
160
+
161
+ # Allowed values (whitelist)
162
+ get '/posts/:status', to: 'posts#show',
163
+ constraints: {
164
+ status: { in: %w[draft published archived] }
165
+ }
166
+
167
+ # Numeric ranges
168
+ get '/products/:price', to: 'products#show',
169
+ constraints: {
170
+ price: { range: 1..10000 }
171
+ }
172
+ end
173
+ ```
174
+
175
+ #### Regular Expression Constraints
176
+
177
+ ```ruby
178
+ router = RubyRoutes.draw do
179
+ # Custom regex pattern (with ReDoS protection)
180
+ get '/products/:sku', to: 'products#show',
181
+ constraints: { sku: /\A[A-Z]{2}\d{4}\z/ }
182
+ end
183
+ ```
184
+
185
+ #### ⚠️ Security Notice: Proc Constraints Deprecated
186
+
187
+ ```ruby
188
+ # ❌ DEPRECATED - Security risk!
189
+ get '/users/:id', to: 'users#show',
190
+ constraints: { id: ->(value) { value.to_i > 0 } }
191
+
192
+ # ✅ Use secure alternatives instead:
193
+ get '/users/:id', to: 'users#show',
194
+ constraints: { id: { range: 1..Float::INFINITY } }
195
+ ```
196
+
197
+ **📚 For complete constraint documentation, see [CONSTRAINTS.md](CONSTRAINTS.md)**
198
+ **🔄 For migration help, see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md)**
199
+
200
+ ### Scopes
201
+
202
+ ```ruby
203
+ router = RubyRoutes.draw do
128
204
  scope defaults: { format: 'html' } do
129
205
  get '/posts', to: 'posts#index'
130
206
  end
@@ -274,13 +350,38 @@ Creates a new router instance and yields to the block for route definition.
274
350
  - `find_route(method, path)` - Finds a specific route
275
351
  - `find_named_route(name)` - Finds a named route
276
352
 
277
- ## Examples
353
+ ## Documentation
354
+
355
+ ### Core Documentation
356
+ - **[CONSTRAINTS.md](CONSTRAINTS.md)** - Complete guide to route constraints and security best practices
357
+ - **[MIGRATION_GUIDE.md](MIGRATION_GUIDE.md)** - Step-by-step guide for migrating from deprecated Proc constraints
358
+
359
+ ### Examples
278
360
 
279
361
  See the `examples/` directory for more detailed examples:
280
362
 
281
363
  - `examples/basic_usage.rb` - Basic routing examples
282
364
  - `examples/rack_integration.rb` - Full Rack application example
283
365
 
366
+ ## Security
367
+
368
+ Ruby Routes prioritizes security and has implemented several protections:
369
+
370
+ ### 🔒 Security Features
371
+ - **XSS Protection**: All HTML output is properly escaped
372
+ - **ReDoS Protection**: Regular expression constraints have timeout protection
373
+ - **Secure Constraints**: Deprecated dangerous Proc constraints in favor of secure alternatives
374
+ - **Thread Safety**: All caching and shared resources are thread-safe
375
+ - **Input Validation**: Comprehensive parameter validation before reaching application code
376
+
377
+ ### ⚠️ Important Security Notice
378
+ **Proc constraints are deprecated due to security risks** and will be removed in a future version. They allow arbitrary code execution which can be exploited for:
379
+ - Code injection attacks
380
+ - Denial of service attacks
381
+ - System compromise
382
+
383
+ **Migration Required**: If you're using Proc constraints, please migrate to secure alternatives using our [Migration Guide](MIGRATION_GUIDE.md).
384
+
284
385
  ## Testing
285
386
 
286
387
  Run the test suite:
@@ -289,6 +390,8 @@ Run the test suite:
289
390
  bundle exec rspec
290
391
  ```
291
392
 
393
+ The test suite includes comprehensive security tests to ensure all protections are working correctly.
394
+
292
395
  ## Contributing
293
396
 
294
397
  1. Fork the repository
@@ -42,9 +42,9 @@ module RubyRoutes
42
42
 
43
43
  # Descriptor factories for segment classification (O(1) dispatch by first byte).
44
44
  DESCRIPTOR_FACTORIES = {
45
- 42 => ->(s) { { type: :splat, name: (s[1..-1] || 'splat') } }, # '*'
46
- 58 => ->(s) { { type: :param, name: s[1..-1] } }, # ':'
47
- :default => ->(s) { { type: :static, value: s } }
45
+ 42 => ->(s) { { type: :splat, name: (s[1..-1] || 'splat').freeze } }, # '*'
46
+ 58 => ->(s) { { type: :param, name: s[1..-1].freeze } }, # ':'
47
+ :default => ->(s) { { type: :static, value: s.freeze } } # Intern static values
48
48
  }.freeze
49
49
 
50
50
  def self.segment_descriptor(raw)
@@ -14,43 +14,52 @@ module RubyRoutes
14
14
  @is_endpoint = false
15
15
  end
16
16
 
17
- # Traverse for a single segment using the matcher registry.
17
+ # Fast traversal: minimal allocations, streamlined branching
18
18
  # Returns [next_node_or_nil, should_break_bool] or [nil, false] if no match.
19
19
  def traverse_for(segment, index, segments, params)
20
- # Prefer static children first (exact match).
21
- if @static_children.key?(segment)
22
- return [@static_children[segment], false]
23
- end
20
+ # Static match: O(1) hash lookup
21
+ child = @static_children[segment]
22
+ return [child, false] if child
24
23
 
25
- # Then dynamic param child (single segment)
26
- if @dynamic_child
27
- next_node = @dynamic_child
28
- if params
29
- params[next_node.param_name.to_s] = segment
30
- end
31
- return [next_node, false]
24
+ # Dynamic match: single segment capture
25
+ if (dyn = @dynamic_child)
26
+ params[dyn.param_name] = segment if params
27
+ return [dyn, false]
32
28
  end
33
29
 
34
- # Then wildcard child (consume remainder)
35
- if @wildcard_child
36
- next_node = @wildcard_child
30
+ # Wildcard match: consume remainder (last resort)
31
+ if (wild = @wildcard_child)
37
32
  if params
38
- params[next_node.param_name.to_s] = segments[index..-1].join('/')
33
+ # Build remainder path without intermediate array allocation
34
+ remainder = segments[index..-1]
35
+ params[wild.param_name] = remainder.size == 1 ? remainder[0] : remainder.join('/')
39
36
  end
40
- return [next_node, true]
37
+ return [wild, true]
41
38
  end
42
39
 
43
- # No match at this node
40
+ # No match
44
41
  [nil, false]
45
42
  end
46
43
 
44
+ # Pre-cache param names as strings to avoid repeated .to_s calls
45
+ def param_name
46
+ @param_name_str ||= @param_name&.to_s
47
+ end
48
+
49
+ def param_name=(name)
50
+ @param_name = name
51
+ @param_name_str = nil # invalidate cache
52
+ end
53
+
54
+ # Normalize method once and cache string keys
47
55
  def add_handler(method, handler)
48
- @handlers[method.to_s] = handler
56
+ method_key = method.to_s.upcase
57
+ @handlers[method_key] = handler
49
58
  @is_endpoint = true
50
59
  end
51
60
 
52
61
  def get_handler(method)
53
- @handlers[method.to_s]
62
+ @handlers[method] # assume already normalized upstream
54
63
  end
55
64
  end
56
65
  end
@@ -4,12 +4,8 @@ module RubyRoutes
4
4
  class RadixTree
5
5
  class << self
6
6
  # Allow RadixTree.new(path, options...) to act as a convenience factory
7
- # returning a Route (this matches test usage where specs call
8
- # RadixTree.new('/path', to: 'controller#action')).
9
- # Calling RadixTree.new with no arguments returns an actual RadixTree instance.
10
7
  def new(*args, &block)
11
8
  if args.any?
12
- # Delegate to Route initializer when args are provided
13
9
  RubyRoutes::Route.new(*args, &block)
14
10
  else
15
11
  super()
@@ -19,47 +15,85 @@ module RubyRoutes
19
15
 
20
16
  def initialize
21
17
  @root = Node.new
22
- @_split_cache = {} # simple LRU: key -> [value, age]
23
- @split_cache_order = [] # track order for eviction
24
- @split_cache_max = 1024
18
+ @split_cache = {}
19
+ @split_cache_order = []
20
+ @split_cache_max = 2048 # larger cache for better hit rates
21
+ @empty_segments = [].freeze # reuse for root path
25
22
  end
26
23
 
27
24
  def add(path, methods, handler)
28
25
  current = @root
29
- parse_segments(path).each do |seg|
26
+ segments = split_path_raw(path)
27
+
28
+ segments.each do |raw_seg|
29
+ seg = RubyRoutes::Segment.for(raw_seg)
30
30
  current = seg.ensure_child(current)
31
31
  break if seg.wildcard?
32
32
  end
33
33
 
34
- methods.each { |method| current.add_handler(method, handler) }
35
- end
36
-
37
- def parse_segments(path)
38
- split_path(path).map { |s| RubyRoutes::Segment.for(s) }
34
+ # Normalize methods once during registration
35
+ Array(methods).each { |method| current.add_handler(method.to_s.upcase, handler) }
39
36
  end
40
37
 
41
38
  def find(path, method, params_out = nil)
42
- segments = split_path(path)
39
+ # Fast path: root route
40
+ if path == '/' || path.empty?
41
+ handler = @root.get_handler(method)
42
+ if @root.is_endpoint && handler
43
+ return [handler, params_out || {}]
44
+ else
45
+ return [nil, {}]
46
+ end
47
+ end
48
+
49
+ segments = split_path_cached(path)
43
50
  current = @root
44
51
  params = params_out || {}
45
52
  params.clear if params_out
46
53
 
47
- segments.each_with_index do |text, idx|
48
- next_node, should_break = current.traverse_for(text, idx, segments, params)
54
+ # Unrolled traversal for common case (1-3 segments)
55
+ case segments.size
56
+ when 1
57
+ next_node, _ = current.traverse_for(segments[0], 0, segments, params)
58
+ current = next_node
59
+ when 2
60
+ next_node, should_break = current.traverse_for(segments[0], 0, segments, params)
61
+ return [nil, {}] unless next_node
62
+ current = next_node
63
+ unless should_break
64
+ next_node, _ = current.traverse_for(segments[1], 1, segments, params)
65
+ current = next_node
66
+ end
67
+ when 3
68
+ next_node, should_break = current.traverse_for(segments[0], 0, segments, params)
49
69
  return [nil, {}] unless next_node
50
70
  current = next_node
51
- break if should_break
71
+ unless should_break
72
+ next_node, should_break = current.traverse_for(segments[1], 1, segments, params)
73
+ return [nil, {}] unless next_node
74
+ current = next_node
75
+ unless should_break
76
+ next_node, _ = current.traverse_for(segments[2], 2, segments, params)
77
+ current = next_node
78
+ end
79
+ end
80
+ else
81
+ # General case for longer paths
82
+ segments.each_with_index do |text, idx|
83
+ next_node, should_break = current.traverse_for(text, idx, segments, params)
84
+ return [nil, {}] unless next_node
85
+ current = next_node
86
+ break if should_break
87
+ end
52
88
  end
53
89
 
90
+ return [nil, {}] unless current
54
91
  handler = current.get_handler(method)
55
92
  return [nil, {}] unless current.is_endpoint && handler
56
93
 
57
- # lightweight constraint checks: reject early if route constraints don't match
58
- route = handler
59
- if route.respond_to?(:constraints) && route.constraints.any?
60
- unless constraints_match?(route.constraints, params)
61
- return [nil, {}]
62
- end
94
+ # Fast constraint check
95
+ if handler.respond_to?(:constraints) && !handler.constraints.empty?
96
+ return [nil, {}] unless constraints_match_fast(handler.constraints, params)
63
97
  end
64
98
 
65
99
  [handler, params]
@@ -67,34 +101,48 @@ module RubyRoutes
67
101
 
68
102
  private
69
103
 
70
- # faster, lower-allocation trim + split
71
- def split_path(path)
72
- @split_cache ||= {}
73
- return [''] if path == '/'
104
+ # Cached path splitting with optimized common cases
105
+ def split_path_cached(path)
106
+ return @empty_segments if path == '/' || path.empty?
107
+
74
108
  if (cached = @split_cache[path])
75
109
  return cached
76
110
  end
77
111
 
78
- p = path
79
- p = p[1..-1] if p.start_with?('/')
80
- p = p[0...-1] if p.end_with?('/')
81
- segs = p.split('/')
112
+ result = split_path_raw(path)
82
113
 
83
- # simple LRU insert
84
- @split_cache[path] = segs
114
+ # Cache with simple LRU eviction
115
+ @split_cache[path] = result
85
116
  @split_cache_order << path
86
117
  if @split_cache_order.size > @split_cache_max
87
118
  oldest = @split_cache_order.shift
88
119
  @split_cache.delete(oldest)
89
120
  end
90
121
 
91
- segs
122
+ result
123
+ end
124
+
125
+ # Raw path splitting without caching (for registration)
126
+ def split_path_raw(path)
127
+ return [] if path == '/' || path.empty?
128
+
129
+ # Optimized trimming: avoid string allocations when possible
130
+ start_idx = path.start_with?('/') ? 1 : 0
131
+ end_idx = path.end_with?('/') ? -2 : -1
132
+
133
+ if start_idx == 0 && end_idx == -1
134
+ path.split('/')
135
+ else
136
+ path[start_idx..end_idx].split('/')
137
+ end
92
138
  end
93
139
 
94
- # constraints match helper (non-raising, lightweight)
95
- def constraints_match?(constraints, params)
140
+ # Optimized constraint matching with fast paths
141
+ def constraints_match_fast(constraints, params)
96
142
  constraints.each do |param, constraint|
97
- value = params[param.to_s] || params[param]
143
+ # Try both string and symbol keys (common pattern)
144
+ value = params[param.to_s]
145
+ value ||= params[param] if param.respond_to?(:to_s)
98
146
  next unless value
99
147
 
100
148
  case constraint
@@ -102,15 +150,16 @@ module RubyRoutes
102
150
  return false unless constraint.match?(value)
103
151
  when Proc
104
152
  return false unless constraint.call(value)
153
+ when :int
154
+ # Fast integer check without regex
155
+ return false unless value.is_a?(String) && value.match?(/\A\d+\z/)
156
+ when :uuid
157
+ # Fast UUID check
158
+ return false unless value.is_a?(String) && value.length == 36 &&
159
+ value.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
105
160
  when Symbol
106
- case constraint
107
- when :int then return false unless value.match?(/^\d+$/)
108
- when :uuid then return false unless value.match?(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
109
- else
110
- # unknown symbol constraint — be conservative and allow
111
- end
112
- else
113
- # unknown constraint type — allow (Route will validate later if needed)
161
+ # Handle other symbolic constraints
162
+ next # unknown symbol constraint allow
114
163
  end
115
164
  end
116
165
  true