rhales 0.3.0 → 0.5.3
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/renovate.json5 +52 -0
- data/.github/workflows/ci.yml +123 -0
- data/.github/workflows/claude-code-review.yml +69 -0
- data/.github/workflows/claude.yml +49 -0
- data/.github/workflows/code-smells.yml +146 -0
- data/.github/workflows/ruby-lint.yml +78 -0
- data/.github/workflows/yardoc.yml +126 -0
- data/.gitignore +55 -0
- data/.pr_agent.toml +63 -0
- data/.pre-commit-config.yaml +89 -0
- data/.prettierignore +8 -0
- data/.prettierrc +38 -0
- data/.reek.yml +98 -0
- data/.rubocop.yml +428 -0
- data/.serena/.gitignore +3 -0
- data/.yardopts +56 -0
- data/CHANGELOG.md +44 -0
- data/CLAUDE.md +1 -2
- data/Gemfile +29 -0
- data/Gemfile.lock +189 -0
- data/README.md +706 -589
- data/Rakefile +46 -0
- data/debug_context.rb +25 -0
- data/demo/rhales-roda-demo/.gitignore +7 -0
- data/demo/rhales-roda-demo/Gemfile +32 -0
- data/demo/rhales-roda-demo/Gemfile.lock +151 -0
- data/demo/rhales-roda-demo/MAIL.md +405 -0
- data/demo/rhales-roda-demo/README.md +376 -0
- data/demo/rhales-roda-demo/RODA-TEMPLATE-ENGINES.md +192 -0
- data/demo/rhales-roda-demo/Rakefile +49 -0
- data/demo/rhales-roda-demo/app.rb +325 -0
- data/demo/rhales-roda-demo/bin/rackup +26 -0
- data/demo/rhales-roda-demo/config.ru +13 -0
- data/demo/rhales-roda-demo/db/migrate/001_create_rodauth_tables.rb +266 -0
- data/demo/rhales-roda-demo/db/migrate/002_create_rodauth_password_tables.rb +79 -0
- data/demo/rhales-roda-demo/db/migrate/003_add_admin_account.rb +68 -0
- data/demo/rhales-roda-demo/templates/change_login.rue +31 -0
- data/demo/rhales-roda-demo/templates/change_password.rue +36 -0
- data/demo/rhales-roda-demo/templates/close_account.rue +31 -0
- data/demo/rhales-roda-demo/templates/create_account.rue +40 -0
- data/demo/rhales-roda-demo/templates/dashboard.rue +150 -0
- data/demo/rhales-roda-demo/templates/home.rue +78 -0
- data/demo/rhales-roda-demo/templates/layouts/main.rue +168 -0
- data/demo/rhales-roda-demo/templates/login.rue +65 -0
- data/demo/rhales-roda-demo/templates/logout.rue +25 -0
- data/demo/rhales-roda-demo/templates/reset_password.rue +26 -0
- data/demo/rhales-roda-demo/templates/verify_account.rue +27 -0
- data/demo/rhales-roda-demo/test_full_output.rb +27 -0
- data/demo/rhales-roda-demo/test_simple.rb +24 -0
- data/docs/.gitignore +9 -0
- data/docs/architecture/data-flow.md +499 -0
- data/examples/dashboard-with-charts.rue +271 -0
- data/examples/form-with-validation.rue +180 -0
- data/examples/simple-page.rue +61 -0
- data/examples/vue.rue +136 -0
- data/generate-json-schemas.ts +158 -0
- data/json_schemer_migration_summary.md +172 -0
- data/lib/rhales/adapters/base_auth.rb +2 -0
- data/lib/rhales/adapters/base_request.rb +2 -0
- data/lib/rhales/adapters/base_session.rb +2 -0
- data/lib/rhales/adapters.rb +7 -0
- data/lib/rhales/configuration.rb +161 -1
- data/lib/rhales/core/context.rb +354 -0
- data/lib/rhales/{rue_document.rb → core/rue_document.rb} +59 -43
- data/lib/rhales/{template_engine.rb → core/template_engine.rb} +80 -33
- data/lib/rhales/core/view.rb +529 -0
- data/lib/rhales/{view_composition.rb → core/view_composition.rb} +81 -9
- data/lib/rhales/core.rb +9 -0
- data/lib/rhales/errors/hydration_collision_error.rb +2 -0
- data/lib/rhales/errors.rb +2 -0
- data/lib/rhales/hydration/earliest_injection_detector.rb +153 -0
- data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
- data/lib/rhales/hydration/hydration_endpoint.rb +215 -0
- data/lib/rhales/hydration/hydration_injector.rb +175 -0
- data/lib/rhales/{hydration_registry.rb → hydration/hydration_registry.rb} +2 -0
- data/lib/rhales/hydration/hydrator.rb +102 -0
- data/lib/rhales/hydration/link_based_injection_detector.rb +195 -0
- data/lib/rhales/hydration/mount_point_detector.rb +109 -0
- data/lib/rhales/hydration/safe_injection_validator.rb +103 -0
- data/lib/rhales/hydration.rb +13 -0
- data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +7 -13
- data/lib/rhales/{tilt.rb → integrations/tilt.rb} +26 -18
- data/lib/rhales/integrations.rb +6 -0
- data/lib/rhales/middleware/json_responder.rb +191 -0
- data/lib/rhales/middleware/schema_validator.rb +300 -0
- data/lib/rhales/middleware.rb +6 -0
- data/lib/rhales/parsers/handlebars_parser.rb +2 -0
- data/lib/rhales/parsers/rue_format_parser.rb +55 -36
- data/lib/rhales/parsers.rb +9 -0
- data/lib/rhales/{csp.rb → security/csp.rb} +27 -3
- data/lib/rhales/utils/json_serializer.rb +114 -0
- data/lib/rhales/utils/logging_helpers.rb +75 -0
- data/lib/rhales/utils/schema_extractor.rb +132 -0
- data/lib/rhales/utils/schema_generator.rb +194 -0
- data/lib/rhales/utils.rb +40 -0
- data/lib/rhales/version.rb +5 -1
- data/lib/rhales.rb +47 -19
- data/lib/tasks/rhales_schema.rake +197 -0
- data/package.json +10 -0
- data/pnpm-lock.yaml +345 -0
- data/pnpm-workspace.yaml +2 -0
- data/proofs/error_handling.rb +79 -0
- data/proofs/expanded_object_inheritance.rb +82 -0
- data/proofs/partial_context_scoping_fix.rb +168 -0
- data/proofs/ui_context_partial_inheritance.rb +236 -0
- data/rhales.gemspec +14 -6
- data/schema_vs_data_comparison.md +254 -0
- data/test_direct_access.rb +36 -0
- metadata +142 -18
- data/CLAUDE.locale.txt +0 -7
- data/lib/rhales/context.rb +0 -240
- data/lib/rhales/hydration_data_aggregator.rb +0 -220
- data/lib/rhales/hydrator.rb +0 -141
- data/lib/rhales/parsers/handlebars-grammar-review.txt +0 -39
- data/lib/rhales/view.rb +0 -412
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# lib/rhales/middleware/schema_validator.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'json_schemer'
|
|
6
|
+
require_relative '../utils/json_serializer'
|
|
7
|
+
|
|
8
|
+
module Rhales
|
|
9
|
+
module Middleware
|
|
10
|
+
# Rack middleware that validates hydration data against JSON Schemas
|
|
11
|
+
#
|
|
12
|
+
# This middleware extracts hydration JSON from HTML responses and validates
|
|
13
|
+
# it against the JSON Schema for the template. In development, it fails
|
|
14
|
+
# loudly on mismatches. In production, it logs warnings but continues serving.
|
|
15
|
+
#
|
|
16
|
+
# @example Basic usage with Rack
|
|
17
|
+
# use Rhales::Middleware::SchemaValidator,
|
|
18
|
+
# schemas_dir: './public/schemas',
|
|
19
|
+
# fail_on_error: ENV['RACK_ENV'] == 'development'
|
|
20
|
+
#
|
|
21
|
+
# @example With Roda
|
|
22
|
+
# use Rhales::Middleware::SchemaValidator,
|
|
23
|
+
# schemas_dir: File.expand_path('../public/schemas', __dir__),
|
|
24
|
+
# fail_on_error: ENV['RACK_ENV'] == 'development',
|
|
25
|
+
# enabled: true
|
|
26
|
+
#
|
|
27
|
+
# @example Accessing statistics
|
|
28
|
+
# validator = app.middleware.find { |m| m.is_a?(Rhales::Middleware::SchemaValidator) }
|
|
29
|
+
# puts validator.stats
|
|
30
|
+
class SchemaValidator
|
|
31
|
+
# Raised when schema validation fails in development mode
|
|
32
|
+
class ValidationError < StandardError; end
|
|
33
|
+
|
|
34
|
+
# Initialize the middleware
|
|
35
|
+
#
|
|
36
|
+
# @param app [#call] The Rack application
|
|
37
|
+
# @param options [Hash] Configuration options
|
|
38
|
+
# @option options [String] :schemas_dir Path to JSON schemas directory
|
|
39
|
+
# @option options [Boolean] :fail_on_error Whether to raise on validation errors
|
|
40
|
+
# @option options [Boolean] :enabled Whether validation is enabled
|
|
41
|
+
# @option options [Array<String>] :skip_paths Additional paths to skip validation
|
|
42
|
+
def initialize(app, options = {})
|
|
43
|
+
@app = app
|
|
44
|
+
# Default to public/schemas in implementing project's directory
|
|
45
|
+
@schemas_dir = options.fetch(:schemas_dir, './public/schemas')
|
|
46
|
+
@fail_on_error = options.fetch(:fail_on_error, false)
|
|
47
|
+
@enabled = options.fetch(:enabled, true)
|
|
48
|
+
@skip_paths = options.fetch(:skip_paths, [])
|
|
49
|
+
@schema_cache = {}
|
|
50
|
+
@stats = {
|
|
51
|
+
total_validations: 0,
|
|
52
|
+
total_time_ms: 0,
|
|
53
|
+
failures: 0
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Process the Rack request
|
|
58
|
+
#
|
|
59
|
+
# @param env [Hash] The Rack environment
|
|
60
|
+
# @return [Array] Rack response tuple [status, headers, body]
|
|
61
|
+
def call(env)
|
|
62
|
+
return @app.call(env) unless @enabled
|
|
63
|
+
return @app.call(env) if skip_validation?(env)
|
|
64
|
+
|
|
65
|
+
status, headers, body = @app.call(env)
|
|
66
|
+
|
|
67
|
+
# Only validate HTML responses
|
|
68
|
+
content_type = headers['Content-Type']
|
|
69
|
+
return [status, headers, body] unless content_type&.include?('text/html')
|
|
70
|
+
|
|
71
|
+
# Get template name from env (set by View)
|
|
72
|
+
template_name = env['rhales.template_name']
|
|
73
|
+
return [status, headers, body] unless template_name
|
|
74
|
+
|
|
75
|
+
# Get template path if available (for better error messages)
|
|
76
|
+
template_path = env['rhales.template_path']
|
|
77
|
+
|
|
78
|
+
# Load schema for template
|
|
79
|
+
schema = load_schema_cached(template_name)
|
|
80
|
+
return [status, headers, body] unless schema
|
|
81
|
+
|
|
82
|
+
# Extract hydration data from response
|
|
83
|
+
html_body = extract_body(body)
|
|
84
|
+
hydration_data = extract_hydration_data(html_body)
|
|
85
|
+
return [status, headers, body] if hydration_data.empty?
|
|
86
|
+
|
|
87
|
+
# Validate each hydration block
|
|
88
|
+
start_time = Time.now
|
|
89
|
+
errors = validate_hydration_data(hydration_data, schema, template_name)
|
|
90
|
+
elapsed_ms = ((Time.now - start_time) * 1000).round(2)
|
|
91
|
+
|
|
92
|
+
# Update stats
|
|
93
|
+
@stats[:total_validations] += 1
|
|
94
|
+
@stats[:total_time_ms] += elapsed_ms
|
|
95
|
+
@stats[:failures] += 1 if errors.any?
|
|
96
|
+
|
|
97
|
+
# Handle errors
|
|
98
|
+
handle_errors(errors, template_name, template_path, elapsed_ms) if errors.any?
|
|
99
|
+
|
|
100
|
+
[status, headers, body]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Get validation statistics
|
|
104
|
+
#
|
|
105
|
+
# @return [Hash] Statistics including avg_time_ms and success_rate
|
|
106
|
+
def stats
|
|
107
|
+
avg_time = @stats[:total_validations] > 0 ?
|
|
108
|
+
(@stats[:total_time_ms] / @stats[:total_validations]).round(2) : 0
|
|
109
|
+
|
|
110
|
+
@stats.merge(
|
|
111
|
+
avg_time_ms: avg_time,
|
|
112
|
+
success_rate: @stats[:total_validations] > 0 ?
|
|
113
|
+
((@stats[:total_validations] - @stats[:failures]).to_f / @stats[:total_validations] * 100).round(2) : 0
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# Check if validation should be skipped for this request
|
|
120
|
+
def skip_validation?(env)
|
|
121
|
+
path = env['PATH_INFO']
|
|
122
|
+
|
|
123
|
+
# Skip static assets, APIs, public files
|
|
124
|
+
return true if path.start_with?('/assets', '/api', '/public')
|
|
125
|
+
|
|
126
|
+
# Skip configured custom paths
|
|
127
|
+
return true if @skip_paths.any? { |skip_path| path.start_with?(skip_path) }
|
|
128
|
+
|
|
129
|
+
# Skip files with extensions typically not rendered by templates
|
|
130
|
+
return true if path.match?(/\.(css|js|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)\z/i)
|
|
131
|
+
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Load and cache JSON schema for template
|
|
136
|
+
def load_schema_cached(template_name)
|
|
137
|
+
@schema_cache[template_name] ||= begin
|
|
138
|
+
schema_path = File.join(@schemas_dir, "#{template_name}.json")
|
|
139
|
+
|
|
140
|
+
return nil unless File.exist?(schema_path)
|
|
141
|
+
|
|
142
|
+
schema_json = File.read(schema_path)
|
|
143
|
+
schema_hash = JSONSerializer.parse(schema_json)
|
|
144
|
+
|
|
145
|
+
# Create JSONSchemer validator
|
|
146
|
+
# Note: json_schemer handles $schema and $id properly
|
|
147
|
+
JSONSchemer.schema(schema_hash)
|
|
148
|
+
rescue JSON::ParserError => e
|
|
149
|
+
warn "Rhales::SchemaValidator: Failed to parse schema for #{template_name}: #{e.message}"
|
|
150
|
+
nil
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
warn "Rhales::SchemaValidator: Failed to load schema for #{template_name}: #{e.message}"
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Extract response body as string
|
|
158
|
+
def extract_body(body)
|
|
159
|
+
if body.respond_to?(:each)
|
|
160
|
+
body.each.to_a.join
|
|
161
|
+
elsif body.respond_to?(:read)
|
|
162
|
+
body.read
|
|
163
|
+
else
|
|
164
|
+
body.to_s
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Extract hydration JSON blocks from HTML
|
|
169
|
+
#
|
|
170
|
+
# Looks for <script type="application/json" data-window="varName"> tags
|
|
171
|
+
def extract_hydration_data(html)
|
|
172
|
+
hydration_blocks = {}
|
|
173
|
+
|
|
174
|
+
# Match script tags with data-window attribute
|
|
175
|
+
html.scan(/<script[^>]*type=["']application\/json["'][^>]*data-window=["']([^"']+)["'][^>]*>(.*?)<\/script>/m) do |window_var, json_content|
|
|
176
|
+
begin
|
|
177
|
+
hydration_blocks[window_var] = JSONSerializer.parse(json_content.strip)
|
|
178
|
+
rescue JSON::ParserError => e
|
|
179
|
+
warn "Rhales::SchemaValidator: Failed to parse hydration JSON for window.#{window_var}: #{e.message}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
hydration_blocks
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Validate hydration data against schema
|
|
187
|
+
def validate_hydration_data(hydration_data, schema, template_name)
|
|
188
|
+
errors = []
|
|
189
|
+
|
|
190
|
+
hydration_data.each do |window_var, data|
|
|
191
|
+
# Validate data against schema using json_schemer
|
|
192
|
+
begin
|
|
193
|
+
validation_errors = schema.validate(data).to_a
|
|
194
|
+
|
|
195
|
+
if validation_errors.any?
|
|
196
|
+
errors << {
|
|
197
|
+
window: window_var,
|
|
198
|
+
template: template_name,
|
|
199
|
+
errors: format_errors(validation_errors)
|
|
200
|
+
}
|
|
201
|
+
end
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
warn "Rhales::SchemaValidator: Schema validation error for #{template_name}: #{e.message}"
|
|
204
|
+
# Don't add to errors array - this is a schema definition problem, not data problem
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
errors
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Format json_schemer errors for display
|
|
212
|
+
def format_errors(validation_errors)
|
|
213
|
+
validation_errors.map do |error|
|
|
214
|
+
# json_schemer provides detailed error hash
|
|
215
|
+
# Example: { "data" => value, "data_pointer" => "/user/id", "schema" => {...}, "type" => "required", "error" => "..." }
|
|
216
|
+
|
|
217
|
+
path = error['data_pointer'] || '/'
|
|
218
|
+
type = error['type']
|
|
219
|
+
schema = error['schema'] || {}
|
|
220
|
+
data = error['data']
|
|
221
|
+
|
|
222
|
+
# For type validation errors, format like json-schema did
|
|
223
|
+
# "The property '#/count' of type string did not match the following type: number"
|
|
224
|
+
if schema['type'] && data
|
|
225
|
+
expected = schema['type']
|
|
226
|
+
actual = case data
|
|
227
|
+
when String then 'string'
|
|
228
|
+
when Integer, Float then 'number'
|
|
229
|
+
when TrueClass, FalseClass then 'boolean'
|
|
230
|
+
when Array then 'array'
|
|
231
|
+
when Hash then 'object'
|
|
232
|
+
when NilClass then 'null'
|
|
233
|
+
else data.class.name.downcase
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
"The property '#{path}' of type #{actual} did not match the following type: #{expected}"
|
|
237
|
+
elsif type == 'required'
|
|
238
|
+
details = error['details'] || {}
|
|
239
|
+
missing = details['missing_keys']&.join(', ') || 'unknown'
|
|
240
|
+
"The property '#{path}' is missing required field(s): #{missing}"
|
|
241
|
+
elsif schema['enum']
|
|
242
|
+
expected = schema['enum'].join(', ')
|
|
243
|
+
"The property '#{path}' must be one of: #{expected}"
|
|
244
|
+
elsif schema['minimum']
|
|
245
|
+
min = schema['minimum']
|
|
246
|
+
"The property '#{path}' must be >= #{min}"
|
|
247
|
+
elsif schema['maximum']
|
|
248
|
+
max = schema['maximum']
|
|
249
|
+
"The property '#{path}' must be <= #{max}"
|
|
250
|
+
elsif type == 'additionalProperties'
|
|
251
|
+
"The property '#{path}' is not defined in the schema and the schema does not allow additional properties"
|
|
252
|
+
else
|
|
253
|
+
# Fallback: use json_schemer's built-in error message
|
|
254
|
+
error['error'] || "The property '#{path}' failed '#{type}' validation"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Handle validation errors
|
|
260
|
+
def handle_errors(errors, template_name, template_path, elapsed_ms)
|
|
261
|
+
error_message = build_error_message(errors, template_name, template_path, elapsed_ms)
|
|
262
|
+
|
|
263
|
+
if @fail_on_error
|
|
264
|
+
# Development: Fail loudly
|
|
265
|
+
raise ValidationError, error_message
|
|
266
|
+
else
|
|
267
|
+
# Production: Log warning
|
|
268
|
+
warn error_message
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Build detailed error message
|
|
273
|
+
def build_error_message(errors, template_name, template_path, elapsed_ms)
|
|
274
|
+
msg = ["Schema validation failed for template: #{template_name}"]
|
|
275
|
+
msg << "Template path: #{template_path}" if template_path
|
|
276
|
+
msg << "Validation time: #{elapsed_ms}ms"
|
|
277
|
+
msg << ""
|
|
278
|
+
|
|
279
|
+
errors.each do |error|
|
|
280
|
+
msg << "Window variable: #{error[:window]}"
|
|
281
|
+
msg << "Errors:"
|
|
282
|
+
error[:errors].each do |err|
|
|
283
|
+
msg << " - #{err}"
|
|
284
|
+
end
|
|
285
|
+
msg << ""
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
msg << "This means your backend is sending data that doesn't match the contract"
|
|
289
|
+
msg << "defined in the <schema> section of #{template_name}.rue"
|
|
290
|
+
msg << ""
|
|
291
|
+
msg << "To fix:"
|
|
292
|
+
msg << "1. Check the schema definition in #{template_name}.rue"
|
|
293
|
+
msg << "2. Verify the data passed to render('#{template_name}', ...)"
|
|
294
|
+
msg << "3. Ensure types match (string vs number, required fields, etc.)"
|
|
295
|
+
|
|
296
|
+
msg.join("\n")
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
# lib/rhales/parsers/rue_format_parser.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
require 'strscan'
|
|
4
6
|
require_relative 'handlebars_parser'
|
|
@@ -9,7 +11,7 @@ module Rhales
|
|
|
9
11
|
# This parser implements .rue file parsing rules in Ruby code and produces
|
|
10
12
|
# an Abstract Syntax Tree (AST) for .rue file processing. It handles:
|
|
11
13
|
#
|
|
12
|
-
# - Section-based parsing: <
|
|
14
|
+
# - Section-based parsing: <schema>, <template>, <logic>
|
|
13
15
|
# - Attribute extraction from section tags
|
|
14
16
|
# - Delegation to HandlebarsParser for template content
|
|
15
17
|
# - Validation of required sections
|
|
@@ -21,19 +23,22 @@ module Rhales
|
|
|
21
23
|
# File format structure:
|
|
22
24
|
# rue_file := section+
|
|
23
25
|
# section := '<' tag_name attributes? '>' content '</' tag_name '>'
|
|
24
|
-
# tag_name := '
|
|
26
|
+
# tag_name := 'schema' | 'template' | 'logic'
|
|
25
27
|
# attributes := attribute+
|
|
26
28
|
# attribute := key '=' quoted_value
|
|
27
29
|
# content := (text | handlebars_expression)*
|
|
28
30
|
# handlebars_expression := '{{' expression '}}'
|
|
29
31
|
class RueFormatParser
|
|
30
32
|
# At least one of these sections must be present
|
|
31
|
-
REQUIRES_ONE_OF_SECTIONS
|
|
32
|
-
|
|
33
|
-
ALL_SECTIONS = KNOWN_SECTIONS.freeze
|
|
33
|
+
unless defined?(REQUIRES_ONE_OF_SECTIONS)
|
|
34
|
+
REQUIRES_ONE_OF_SECTIONS = %w[schema template].freeze
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
KNOWN_SECTIONS = %w[schema template logic].freeze
|
|
37
|
+
ALL_SECTIONS = KNOWN_SECTIONS.freeze
|
|
38
|
+
|
|
39
|
+
# Regular expression to match HTML/XML comments outside of sections
|
|
40
|
+
COMMENT_REGEX = /<!--.*?-->/m
|
|
41
|
+
end
|
|
37
42
|
|
|
38
43
|
class ParseError < ::Rhales::ParseError
|
|
39
44
|
def initialize(message, line: nil, column: nil, offset: nil)
|
|
@@ -145,7 +150,7 @@ module Rhales
|
|
|
145
150
|
def parse_tag_name
|
|
146
151
|
start_pos = @position
|
|
147
152
|
|
|
148
|
-
advance while !at_end? && current_char.match?(/[a-zA-
|
|
153
|
+
advance while !at_end? && current_char.match?(/[a-zA-Z0-9_]/)
|
|
149
154
|
|
|
150
155
|
if start_pos == @position
|
|
151
156
|
parse_error('Expected tag name')
|
|
@@ -178,30 +183,42 @@ module Rhales
|
|
|
178
183
|
attributes
|
|
179
184
|
end
|
|
180
185
|
|
|
186
|
+
# Uses StringScanner to parse "content" in <section>content</section>
|
|
181
187
|
def parse_section_content(tag_name)
|
|
182
|
-
start_pos = @position
|
|
183
188
|
content_start = @position
|
|
189
|
+
closing_tag = "</#{tag_name}>"
|
|
184
190
|
|
|
185
|
-
#
|
|
186
|
-
|
|
187
|
-
while !at_end? && !peek_closing_tag?(tag_name)
|
|
188
|
-
raw_content << current_char
|
|
189
|
-
advance
|
|
190
|
-
end
|
|
191
|
+
# Create scanner from remaining content
|
|
192
|
+
scanner = StringScanner.new(@content[content_start..])
|
|
191
193
|
|
|
192
|
-
#
|
|
193
|
-
if
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
else
|
|
198
|
-
# For data and logic sections, keep as simple text
|
|
199
|
-
return [Node.new(:text, current_location, value: raw_content)] unless raw_content.empty?
|
|
194
|
+
# Find the closing tag position
|
|
195
|
+
if scanner.scan_until(/(?=#{Regexp.escape(closing_tag)})/)
|
|
196
|
+
# Calculate content length (scanner.charpos gives us position right before closing tag)
|
|
197
|
+
content_length = scanner.charpos
|
|
198
|
+
raw_content = @content[content_start, content_length]
|
|
200
199
|
|
|
201
|
-
|
|
200
|
+
# Advance position tracking to end of content
|
|
201
|
+
advance_to_position(content_start + content_length)
|
|
202
|
+
|
|
203
|
+
# Process content based on tag type
|
|
204
|
+
if tag_name == 'template'
|
|
205
|
+
handlebars_parser = HandlebarsParser.new(raw_content)
|
|
206
|
+
handlebars_parser.parse!
|
|
207
|
+
handlebars_parser.ast.children
|
|
208
|
+
else
|
|
209
|
+
# For schema and logic sections, keep as simple text
|
|
210
|
+
raw_content.empty? ? [] : [Node.new(:text, current_location, value: raw_content)]
|
|
211
|
+
end
|
|
212
|
+
else
|
|
213
|
+
parse_error("Expected '#{closing_tag}' to close section")
|
|
202
214
|
end
|
|
203
215
|
end
|
|
204
216
|
|
|
217
|
+
# Add this helper method to advance position tracking to a specific offset
|
|
218
|
+
def advance_to_position(target_position)
|
|
219
|
+
advance while @position < target_position && !at_end?
|
|
220
|
+
end
|
|
221
|
+
|
|
205
222
|
def parse_quoted_string
|
|
206
223
|
quote_char = current_char
|
|
207
224
|
unless ['"', "'"].include?(quote_char)
|
|
@@ -209,7 +226,7 @@ module Rhales
|
|
|
209
226
|
end
|
|
210
227
|
|
|
211
228
|
advance # Skip opening quote
|
|
212
|
-
value =
|
|
229
|
+
value = []
|
|
213
230
|
|
|
214
231
|
while !at_end? && current_char != quote_char
|
|
215
232
|
value << current_char
|
|
@@ -217,7 +234,11 @@ module Rhales
|
|
|
217
234
|
end
|
|
218
235
|
|
|
219
236
|
consume(quote_char) || parse_error('Unterminated quoted string')
|
|
220
|
-
|
|
237
|
+
|
|
238
|
+
# NOTE: Character-by-character parsing is acceptable here since attribute values
|
|
239
|
+
# in section tags (e.g., <tag attribute="value">) are typically short strings.
|
|
240
|
+
# Using StringScanner would be overkill for this use case.
|
|
241
|
+
value.join
|
|
221
242
|
end
|
|
222
243
|
|
|
223
244
|
def parse_identifier
|
|
@@ -350,8 +371,6 @@ module Rhales
|
|
|
350
371
|
result_parts.join
|
|
351
372
|
end
|
|
352
373
|
|
|
353
|
-
private
|
|
354
|
-
|
|
355
374
|
# Tokenize content into structured tokens for pattern matching
|
|
356
375
|
# Uses StringScanner for better performance and cleaner code
|
|
357
376
|
def tokenize_content(content)
|
|
@@ -359,23 +378,23 @@ module Rhales
|
|
|
359
378
|
tokens = []
|
|
360
379
|
|
|
361
380
|
until scanner.eos?
|
|
362
|
-
case
|
|
381
|
+
tokens << case
|
|
363
382
|
when scanner.scan(/<!--.*?-->/m)
|
|
364
383
|
# Comment token - non-greedy match for complete comments
|
|
365
|
-
|
|
366
|
-
when scanner.scan(/<(
|
|
384
|
+
{ type: :comment, content: scanner.matched }
|
|
385
|
+
when scanner.scan(/<(schema|template|logic)(\s[^>]*)?>/m)
|
|
367
386
|
# Section start token - matches opening tags with optional attributes
|
|
368
|
-
|
|
369
|
-
when scanner.scan(
|
|
387
|
+
{ type: :section_start, content: scanner.matched }
|
|
388
|
+
when scanner.scan(%r{</(schema|template|logic)>}m)
|
|
370
389
|
# Section end token - matches closing tags
|
|
371
|
-
|
|
390
|
+
{ type: :section_end, content: scanner.matched }
|
|
372
391
|
when scanner.scan(/[^<]+/)
|
|
373
392
|
# Text token - consolidates runs of non-< characters for efficiency
|
|
374
|
-
|
|
393
|
+
{ type: :text, content: scanner.matched }
|
|
375
394
|
else
|
|
376
395
|
# Fallback for single characters (< that don't match patterns)
|
|
377
396
|
# This maintains compatibility with the original character-by-character behavior
|
|
378
|
-
|
|
397
|
+
{ type: :text, content: scanner.getch }
|
|
379
398
|
end
|
|
380
399
|
end
|
|
381
400
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
# lib/rhales/csp.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
module Rhales
|
|
4
6
|
# Content Security Policy (CSP) header generation and management
|
|
@@ -11,6 +13,8 @@ module Rhales
|
|
|
11
13
|
# header = csp.build_header
|
|
12
14
|
# # => "default-src 'self'; script-src 'self' 'nonce-abc123'; ..."
|
|
13
15
|
class CSP
|
|
16
|
+
include Rhales::Utils::LoggingHelpers
|
|
17
|
+
|
|
14
18
|
attr_reader :config, :nonce
|
|
15
19
|
|
|
16
20
|
def initialize(config, nonce: nil)
|
|
@@ -23,6 +27,7 @@ module Rhales
|
|
|
23
27
|
return nil unless @config.csp_enabled
|
|
24
28
|
|
|
25
29
|
policy_directives = []
|
|
30
|
+
nonce_used = false
|
|
26
31
|
|
|
27
32
|
@config.csp_policy.each do |directive, sources|
|
|
28
33
|
if sources.empty?
|
|
@@ -30,18 +35,37 @@ module Rhales
|
|
|
30
35
|
policy_directives << directive
|
|
31
36
|
else
|
|
32
37
|
# Process sources and interpolate nonce if present
|
|
33
|
-
processed_sources = sources.map
|
|
38
|
+
processed_sources = sources.map do |source|
|
|
39
|
+
interpolated = interpolate_nonce(source)
|
|
40
|
+
nonce_used = true if interpolated != source
|
|
41
|
+
interpolated
|
|
42
|
+
end
|
|
34
43
|
directive_string = "#{directive} #{processed_sources.join(' ')}"
|
|
35
44
|
policy_directives << directive_string
|
|
36
45
|
end
|
|
37
46
|
end
|
|
38
47
|
|
|
39
|
-
policy_directives.join('; ')
|
|
48
|
+
header = policy_directives.join('; ')
|
|
49
|
+
|
|
50
|
+
# Log CSP header generation for security audit
|
|
51
|
+
log_with_metadata(Rhales.logger, :debug, "CSP header generated",
|
|
52
|
+
nonce_used: nonce_used,
|
|
53
|
+
nonce: @nonce,
|
|
54
|
+
directive_count: policy_directives.size,
|
|
55
|
+
header_length: header.length
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
header
|
|
40
59
|
end
|
|
41
60
|
|
|
42
61
|
# Generate a new nonce value
|
|
43
62
|
def self.generate_nonce
|
|
44
|
-
SecureRandom.hex(16)
|
|
63
|
+
nonce = SecureRandom.hex(16)
|
|
64
|
+
|
|
65
|
+
# Log nonce generation for security audit trail
|
|
66
|
+
Rhales.logger.debug("CSP nonce generated: nonce=#{nonce} length=#{nonce.length} entropy_bits=#{nonce.length * 4}")
|
|
67
|
+
|
|
68
|
+
nonce
|
|
45
69
|
end
|
|
46
70
|
|
|
47
71
|
# Validate CSP policy configuration
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# lib/rhales/utils/json_serializer.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
module Rhales
|
|
6
|
+
# Centralized JSON serialization with optional Oj support
|
|
7
|
+
#
|
|
8
|
+
# This module provides a unified interface for JSON operations with automatic
|
|
9
|
+
# Oj optimization when available. Oj is 10-20x faster than stdlib JSON for
|
|
10
|
+
# parsing and 5-10x faster for generation.
|
|
11
|
+
#
|
|
12
|
+
# The serializer backend is determined once at load time for optimal performance.
|
|
13
|
+
# If you want to use Oj, require it before requiring Rhales:
|
|
14
|
+
#
|
|
15
|
+
# @example Ensuring Oj is used
|
|
16
|
+
# require 'oj'
|
|
17
|
+
# require 'rhales'
|
|
18
|
+
#
|
|
19
|
+
# @example Basic usage
|
|
20
|
+
# Rhales::JSONSerializer.dump({ user: 'Alice' })
|
|
21
|
+
# # => "{\"user\":\"Alice\"}"
|
|
22
|
+
#
|
|
23
|
+
# Rhales::JSONSerializer.parse('{"user":"Alice"}')
|
|
24
|
+
# # => {"user"=>"Alice"}
|
|
25
|
+
#
|
|
26
|
+
# @example Pretty printing
|
|
27
|
+
# Rhales::JSONSerializer.pretty_dump({ user: 'Alice', count: 42 })
|
|
28
|
+
# # => "{\n \"user\": \"Alice\",\n \"count\": 42\n}"
|
|
29
|
+
#
|
|
30
|
+
# @example Check backend
|
|
31
|
+
# Rhales::JSONSerializer.backend
|
|
32
|
+
# # => :oj (if available) or :json (stdlib)
|
|
33
|
+
#
|
|
34
|
+
module JSONSerializer
|
|
35
|
+
class << self
|
|
36
|
+
# Serialize Ruby object to JSON string
|
|
37
|
+
#
|
|
38
|
+
# Uses the serializer backend determined at load time (Oj or stdlib JSON).
|
|
39
|
+
#
|
|
40
|
+
# @param obj [Object] Ruby object to serialize
|
|
41
|
+
# @return [String] JSON string (compact format)
|
|
42
|
+
# @raise [TypeError] if object contains non-serializable types
|
|
43
|
+
def dump(obj)
|
|
44
|
+
@json_dumper.call(obj)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Serialize Ruby object to pretty-printed JSON string
|
|
48
|
+
#
|
|
49
|
+
# Uses the serializer backend determined at load time (Oj or stdlib JSON).
|
|
50
|
+
# Output is formatted with indentation for readability.
|
|
51
|
+
#
|
|
52
|
+
# @param obj [Object] Ruby object to serialize
|
|
53
|
+
# @return [String] Pretty-printed JSON string with 2-space indentation
|
|
54
|
+
# @raise [TypeError] if object contains non-serializable types
|
|
55
|
+
def pretty_dump(obj)
|
|
56
|
+
@json_pretty_dumper.call(obj)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Parse JSON string to Ruby object
|
|
60
|
+
#
|
|
61
|
+
# Uses the parser backend determined at load time (Oj or stdlib JSON).
|
|
62
|
+
# Always returns hashes with string keys (not symbols) for consistency.
|
|
63
|
+
#
|
|
64
|
+
# @param json_string [String] JSON string to parse
|
|
65
|
+
# @return [Object] parsed Ruby object (Hash with string keys)
|
|
66
|
+
# @raise [JSON::ParserError, Oj::ParseError] if JSON is malformed
|
|
67
|
+
def parse(json_string)
|
|
68
|
+
@json_loader.call(json_string)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns the active JSON backend
|
|
72
|
+
#
|
|
73
|
+
# @return [Symbol] :oj or :json
|
|
74
|
+
def backend
|
|
75
|
+
@backend
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Reset backend detection (useful for testing)
|
|
79
|
+
#
|
|
80
|
+
# @api private
|
|
81
|
+
def reset!
|
|
82
|
+
detect_backend!
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Detect and configure JSON backend at load time
|
|
88
|
+
def detect_backend!
|
|
89
|
+
oj_available = begin
|
|
90
|
+
require 'oj'
|
|
91
|
+
true
|
|
92
|
+
rescue LoadError
|
|
93
|
+
false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if oj_available
|
|
97
|
+
@backend = :oj
|
|
98
|
+
@json_dumper = ->(obj) { Oj.dump(obj, mode: :strict) }
|
|
99
|
+
@json_pretty_dumper = ->(obj) { Oj.dump(obj, mode: :strict, indent: 2) }
|
|
100
|
+
@json_loader = ->(json_string) { Oj.load(json_string, mode: :strict, symbol_keys: false) }
|
|
101
|
+
else
|
|
102
|
+
require 'json'
|
|
103
|
+
@backend = :json
|
|
104
|
+
@json_dumper = ->(obj) { JSON.generate(obj) }
|
|
105
|
+
@json_pretty_dumper = ->(obj) { JSON.pretty_generate(obj) }
|
|
106
|
+
@json_loader = ->(json_string) { JSON.parse(json_string) }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Initialize backend on load
|
|
112
|
+
detect_backend!
|
|
113
|
+
end
|
|
114
|
+
end
|