odin-foundation 1.2.1 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd44363f49452d2888d4c6adee66875fc13d6cd77e0dac6650028aabbcace2af
4
- data.tar.gz: fa3f640fe709656ffa5f3436ca7f79d287c2d7a2cf5d9470c4fb7d8d89659b4e
3
+ metadata.gz: 936f1721e9f83ea1cb3a0036d03d964354dde32d9b69012e52738a4be45869cf
4
+ data.tar.gz: e17ff98825a5bee6b86648ef70445cc9c2f5ea8b3c7c6a812072d13a968deb12
5
5
  SHA512:
6
- metadata.gz: d8d134498b241ad33fa0936e95b4f5cefcb3642e97ec099509e23d75fcdb2e11df55c8d072e8989557944c0e7ae2ae38abf64e8cff85a76d8be311406fd22dfc
7
- data.tar.gz: dfc5f7a283b5b8714bbb8a9ba09535a49a368dc178dfc8a7b678fee2a8a88d70e7ff8e3347d41ce310f14bccfb083c25e4ca8e3277be7abe20681932d3abbcec
6
+ metadata.gz: b2560371ac52c3c12980318e3ffe96ed6018a355e5cb2cfee9bab578b19e1d03c30873a6f5d8c7f3fc2a8ec58361923f5e1cb0350382e006ce822f83e4b7950e
7
+ data.tar.gz: f5de2705007895ba8cf67b862e788d56d44da4bdfe8ae904e978373bdf68d6e3f3cc13dc75834dd4e088c93a6efe6927779c60d77821dd4bd935cbaf8065397a
@@ -6,6 +6,19 @@ module Odin
6
6
  # Verb registry — populated in Phase 9-10. For now, core verbs only.
7
7
  CORE_VERBS = {}.freeze
8
8
 
9
+ # Verbs whose cost grows with an array argument. Charged proportional to
10
+ # the array width so large-array work cannot escape the fuel budget.
11
+ SORT_VERBS = %w[sort].freeze
12
+ WIDTH_VERBS = %w[
13
+ distinct groupBy keyBy countBy reduce
14
+ sum avg min max count sumIf avgIf countIf
15
+ union intersection difference symmetricDifference
16
+ map filter window explode flatten reverse
17
+ ].freeze
18
+
19
+ # Read the wall clock once per this many charged units.
20
+ CLOCK_CHECK_INTERVAL = 1024
21
+
9
22
  class TransformError < StandardError
10
23
  attr_reader :code
11
24
  attr_accessor :segment, :field
@@ -43,9 +56,20 @@ module Odin
43
56
  end
44
57
  end
45
58
 
59
+ # Execution guard abort (fuel, timeout, or depth). Not downgraded by the
60
+ # onError policy; the execute boundary surfaces it as a failed result.
61
+ class TransformAbortError < StandardError
62
+ attr_reader :transform_error
63
+
64
+ def initialize(transform_error)
65
+ @transform_error = transform_error
66
+ super(transform_error.message)
67
+ end
68
+ end
69
+
46
70
  # ── Transform Error Codes ──
47
- # T001-T010 are reserved for core transform errors.
48
- # T011+ are for implementation-specific errors.
71
+ # T001-T018 are reserved for core transform errors.
72
+ # Later codes are for implementation-specific errors.
49
73
  module ErrorCodes
50
74
  T001_UNKNOWN_VERB = "T001"
51
75
  T002_INVALID_VERB_ARGS = "T002"
@@ -60,6 +84,33 @@ module Odin
60
84
  T011_INCOMPATIBLE_CONVERSION = "T011"
61
85
  T012_DANGLING_BRANCH = "T012"
62
86
  T014_NESTED_INTERPOLATION = "T014"
87
+ T016_TRANSFORM_BUDGET_EXCEEDED = "T016"
88
+ T017_TRANSFORM_TIMEOUT_EXCEEDED = "T017"
89
+ T018_EXPRESSION_DEPTH_EXCEEDED = "T018"
90
+ end
91
+
92
+ # Create a T016 Transform Budget Exceeded error.
93
+ def self.budget_exceeded_error(limit)
94
+ TransformError.new(
95
+ "Transform fuel budget exceeded (limit #{limit})",
96
+ code: ErrorCodes::T016_TRANSFORM_BUDGET_EXCEEDED
97
+ )
98
+ end
99
+
100
+ # Create a T017 Transform Timeout Exceeded error.
101
+ def self.timeout_exceeded_error(limit_ms)
102
+ TransformError.new(
103
+ "Transform timeout exceeded (limit #{limit_ms}ms)",
104
+ code: ErrorCodes::T017_TRANSFORM_TIMEOUT_EXCEEDED
105
+ )
106
+ end
107
+
108
+ # Create a T018 Expression Depth Exceeded error.
109
+ def self.expression_depth_exceeded_error(limit)
110
+ TransformError.new(
111
+ "Expression evaluation depth exceeded (limit #{limit})",
112
+ code: ErrorCodes::T018_EXPRESSION_DEPTH_EXCEEDED
113
+ )
63
114
  end
64
115
 
65
116
  # Create a T014 Nested Interpolation error.
@@ -132,9 +183,28 @@ module Odin
132
183
 
133
184
  def initialize
134
185
  @verb_registry = build_verb_registry
186
+ reset_guard!
135
187
  end
136
188
 
137
- def execute(transform_def, source_data, import_resolver: nil)
189
+ # Reset execution-guard state for a single execute call. Fuel and timeout
190
+ # charge only when their cap is set (> 0); depth uses its standing default.
191
+ # A per-call value overrides the global limit; nil falls back to it.
192
+ def reset_guard!(max_transform_fuel: nil, transform_timeout_ms: nil, max_expression_depth: nil)
193
+ @fuel_cap = max_transform_fuel.nil? ? Utils::SecurityLimits.max_transform_fuel : max_transform_fuel
194
+ @timeout_ms = transform_timeout_ms.nil? ? Utils::SecurityLimits.transform_timeout_ms : transform_timeout_ms
195
+ @max_expr_depth = max_expression_depth.nil? ? Utils::SecurityLimits.max_expression_depth : max_expression_depth
196
+ @fuel_used = 0
197
+ @expr_depth = 0
198
+ @ops_since_clock = 0
199
+ @start_time = @timeout_ms.positive? ? monotonic_ms : 0
200
+ end
201
+
202
+ def execute(transform_def, source_data, import_resolver: nil,
203
+ max_transform_fuel: nil, transform_timeout_ms: nil, max_expression_depth: nil)
204
+ reset_guard!(max_transform_fuel: max_transform_fuel,
205
+ transform_timeout_ms: transform_timeout_ms,
206
+ max_expression_depth: max_expression_depth)
207
+
138
208
  # Merge imported tables, constants, accumulators, and segments.
139
209
  if import_resolver && transform_def.imports && !transform_def.imports.empty?
140
210
  resolve_imports(transform_def, import_resolver)
@@ -159,23 +229,27 @@ module Odin
159
229
 
160
230
  # 3. Process segments (multi-pass support)
161
231
  output = {}
162
- passes = transform_def.passes
163
- if passes.empty?
164
- # Single implicit pass
165
- process_segment_list(transform_def.segments, source, context, output)
166
- else
167
- # Multi-pass: explicit passes first, then pass-0 (implicit)
168
- all_passes = passes.include?(0) ? passes : passes + [0]
169
- first_pass = true
170
- all_passes.each do |pass_num|
171
- unless first_pass
172
- reset_non_persist_accumulators(context, transform_def.accumulators)
173
- end
174
- first_pass = false
232
+ begin
233
+ passes = transform_def.passes
234
+ if passes.empty?
235
+ # Single implicit pass
236
+ process_segment_list(transform_def.segments, source, context, output)
237
+ else
238
+ # Multi-pass: explicit passes first, then pass-0 (implicit)
239
+ all_passes = passes.include?(0) ? passes : passes + [0]
240
+ first_pass = true
241
+ all_passes.each do |pass_num|
242
+ unless first_pass
243
+ reset_non_persist_accumulators(context, transform_def.accumulators)
244
+ end
245
+ first_pass = false
175
246
 
176
- pass_segments = transform_def.segments.select { |s| (s.pass || 0) == pass_num }
177
- process_segment_list(pass_segments, source, context, output)
247
+ pass_segments = transform_def.segments.select { |s| (s.pass || 0) == pass_num }
248
+ process_segment_list(pass_segments, source, context, output)
249
+ end
178
250
  end
251
+ rescue TransformAbortError => e
252
+ return abort_result(e, output, context)
179
253
  end
180
254
 
181
255
  # 4. Apply confidential enforcement
@@ -229,44 +303,48 @@ module Odin
229
303
  end
230
304
 
231
305
  # Process each record/line
232
- lines = raw_input.split(/[\r\n]+/)
233
- lines.each do |line|
234
- next if line.strip.empty?
306
+ begin
307
+ lines = raw_input.split(/[\r\n]+/)
308
+ lines.each do |line|
309
+ next if line.strip.empty?
235
310
 
236
- disc_value = extract_discriminator_value(line, disc, delimiter)
237
- segment = segment_map[disc_value]
238
- next unless segment
311
+ disc_value = extract_discriminator_value(line, disc, delimiter)
312
+ segment = segment_map[disc_value]
313
+ next unless segment
239
314
 
240
- record_source = parse_record(line, source_format, delimiter)
241
- record_output = {}
315
+ record_source = parse_record(line, source_format, delimiter)
316
+ record_output = {}
242
317
 
243
- # Set the record as the current source for path resolution
244
- context.source = record_source
318
+ # Set the record as the current source for path resolution
319
+ context.source = record_source
245
320
 
246
- # Process field mappings
247
- segment.field_mappings.each do |mapping|
248
- process_mapping(mapping, record_source, context, record_output)
249
- end
321
+ # Process field mappings
322
+ segment.field_mappings.each do |mapping|
323
+ process_mapping(mapping, record_source, context, record_output)
324
+ end
250
325
 
251
- # Process children
252
- segment.children.each do |child|
253
- process_segment(child, record_source, context, record_output)
254
- end
326
+ # Process children
327
+ segment.children.each do |child|
328
+ process_segment(child, record_source, context, record_output)
329
+ end
255
330
 
256
- # Merge into output
257
- seg_name = segment.name
331
+ # Merge into output
332
+ seg_name = segment.name
258
333
 
259
- if segment.is_array
260
- array_accumulators[seg_name] ||= []
261
- array_accumulators[seg_name] << record_output
262
- else
263
- # Merge fields into existing segment object
264
- if output[seg_name].is_a?(Hash)
265
- record_output.each { |k, v| output[seg_name][k] = v }
334
+ if segment.is_array
335
+ array_accumulators[seg_name] ||= []
336
+ array_accumulators[seg_name] << record_output
266
337
  else
267
- output[seg_name] = record_output
338
+ # Merge fields into existing segment object
339
+ if output[seg_name].is_a?(Hash)
340
+ record_output.each { |k, v| output[seg_name][k] = v }
341
+ else
342
+ output[seg_name] = record_output
343
+ end
268
344
  end
269
345
  end
346
+ rescue TransformAbortError => e
347
+ return abort_result(e, output, context)
270
348
  end
271
349
 
272
350
  # Merge array accumulators into output in segment order
@@ -391,9 +469,84 @@ module Odin
391
469
  end
392
470
  end
393
471
 
472
+ # ── Execution Guard ──
473
+
474
+ # Surface a guard abort as a failed result; the abort is never raised past
475
+ # the execute boundary. The guard error joins context.errors so the result
476
+ # reports failure.
477
+ def abort_result(err, output, context)
478
+ context.errors << err.transform_error
479
+ plain_output = deep_to_ruby(output)
480
+ TransformResult.new(output: plain_output, formatted: "", errors: context.errors, warnings: context.warnings)
481
+ end
482
+
483
+ # Monotonic clock in milliseconds for the wall-clock guard.
484
+ def monotonic_ms
485
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
486
+ end
487
+
488
+ # Charge fuel and, at a coarse interval, the wall clock. Both are no-ops
489
+ # unless their cap is set (> 0), so unbounded transforms pay nothing.
490
+ def charge(units)
491
+ if @fuel_cap.positive?
492
+ @fuel_used += units
493
+ raise TransformAbortError.new(self.class.budget_exceeded_error(@fuel_cap)) if @fuel_used > @fuel_cap
494
+ end
495
+ return unless @timeout_ms.positive?
496
+
497
+ @ops_since_clock += units
498
+ return if @ops_since_clock < CLOCK_CHECK_INTERVAL
499
+
500
+ @ops_since_clock = 0
501
+ return unless monotonic_ms - @start_time > @timeout_ms
502
+
503
+ raise TransformAbortError.new(self.class.timeout_exceeded_error(@timeout_ms))
504
+ end
505
+
506
+ # Charge width for a verb doing O(n)/O(n log n) work over an array argument,
507
+ # so large-array work cannot escape the budget at a flat unit. Runs when
508
+ # fuel OR timeout is set, so a timeout-only host can interrupt wide verbs.
509
+ def charge_verb_width(verb, args)
510
+ return if @fuel_cap <= 0 && @timeout_ms <= 0
511
+
512
+ n = first_array_width(args)
513
+ return if n <= 0
514
+
515
+ if SORT_VERBS.include?(verb)
516
+ charge(n * Math.log2([n, 2].max).ceil)
517
+ elsif WIDTH_VERBS.include?(verb)
518
+ charge(n)
519
+ end
520
+ end
521
+
522
+ # Width of the first array-typed argument, or 0 if none.
523
+ def first_array_width(args)
524
+ args.each do |arg|
525
+ return arg.value.length if arg.is_a?(Types::DynValue) && arg.array?
526
+ end
527
+ 0
528
+ end
529
+
394
530
  # ── Expression Evaluation ──
395
531
 
532
+ # Guard boundary: enforce depth, charge one base unit of fuel, and batch the
533
+ # wall-clock check before delegating to the evaluator. All evaluation paths
534
+ # funnel through here, so charging stays concentrated at this single point.
396
535
  def evaluate(expr, context)
536
+ @expr_depth += 1
537
+ if @expr_depth > @max_expr_depth
538
+ @expr_depth -= 1
539
+ raise TransformAbortError.new(self.class.expression_depth_exceeded_error(@max_expr_depth))
540
+ end
541
+ charge(1)
542
+ begin
543
+ evaluate_inner(expr, context)
544
+ ensure
545
+ @expr_depth -= 1
546
+ end
547
+ end
548
+
549
+ def evaluate_inner(expr, context)
397
550
  case expr
398
551
  when LiteralExpr
399
552
  val = expr.value
@@ -1111,6 +1264,8 @@ module Odin
1111
1264
  if target.start_with?("_")
1112
1265
  begin
1113
1266
  evaluate(mapping.expression, context)
1267
+ rescue TransformAbortError
1268
+ raise
1114
1269
  rescue StandardError => e
1115
1270
  context.errors << TransformError.new(e.message)
1116
1271
  end
@@ -1227,6 +1382,9 @@ module Odin
1227
1382
  # Store DynValue directly to preserve type information (date, timestamp, etc.)
1228
1383
  dv_val = val.is_a?(Types::DynValue) ? val : Types::DynValue.from_ruby(val)
1229
1384
  set_path(output, target, dv_val)
1385
+ rescue TransformAbortError
1386
+ # Guard aborts are not downgraded by onError.
1387
+ raise
1230
1388
  rescue CodedTransformError => e
1231
1389
  # Coded errors carry a stable T-code; preserve it under fail/warn.
1232
1390
  coded = e.transform_error
@@ -1555,6 +1713,7 @@ module Odin
1555
1713
  end
1556
1714
  val
1557
1715
  end
1716
+ charge_verb_width(verb_name, evaluated_args)
1558
1717
 
1559
1718
  # Special handling for accumulate/set
1560
1719
  case verb_name
@@ -1589,6 +1748,7 @@ module Odin
1589
1748
 
1590
1749
  # Eager evaluation for all other verbs
1591
1750
  evaluated_args = args.map { |arg| evaluate(arg, context) }
1751
+ charge_verb_width(verb_name, evaluated_args)
1592
1752
 
1593
1753
  # Special handling for accumulate/set
1594
1754
  case verb_name
@@ -12,6 +12,32 @@ module Odin
12
12
  MAX_RECORDS = 100_000
13
13
  MAX_ASSIGNMENTS = 100_000
14
14
  MAX_REGEX_PATTERN_LENGTH = 500
15
+ MAX_EXPRESSION_DEPTH = 32
16
+
17
+ # Read an integer limit from ODIN_<name>, falling back to default.
18
+ def self.env_int(name, default)
19
+ raw = ENV["ODIN_#{name}"]
20
+ return default if raw.nil? || raw.strip.empty?
21
+
22
+ Integer(raw)
23
+ rescue ArgumentError
24
+ default
25
+ end
26
+
27
+ # Transform fuel budget; 0 = unbounded.
28
+ def self.max_transform_fuel
29
+ env_int("MAX_TRANSFORM_FUEL", 0)
30
+ end
31
+
32
+ # Transform wall-clock timeout in milliseconds; 0 = unbounded.
33
+ def self.transform_timeout_ms
34
+ env_int("TRANSFORM_TIMEOUT_MS", 0)
35
+ end
36
+
37
+ # Expression evaluation depth cap; standing default when unset.
38
+ def self.max_expression_depth
39
+ env_int("MAX_EXPRESSION_DEPTH", MAX_EXPRESSION_DEPTH)
40
+ end
15
41
  end
16
42
  end
17
43
  end
data/lib/odin/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Odin
4
- VERSION = "1.2.1"
4
+ VERSION = "1.2.3"
5
5
  end
data/lib/odin.rb CHANGED
@@ -174,8 +174,14 @@ module Odin
174
174
  Transform::TransformParser.new.parse(text)
175
175
  end
176
176
 
177
- def execute_transform(transform_def, source, import_resolver: nil)
178
- Transform::TransformEngine.new.execute(transform_def, source, import_resolver: import_resolver)
177
+ def execute_transform(transform_def, source, import_resolver: nil,
178
+ max_transform_fuel: nil, transform_timeout_ms: nil, max_expression_depth: nil)
179
+ Transform::TransformEngine.new.execute(
180
+ transform_def, source, import_resolver: import_resolver,
181
+ max_transform_fuel: max_transform_fuel,
182
+ transform_timeout_ms: transform_timeout_ms,
183
+ max_expression_depth: max_expression_depth
184
+ )
179
185
  end
180
186
 
181
187
  def transform(transform_text, source)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: odin-foundation
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - ODIN Foundation
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-12 00:00:00.000000000 Z
11
+ date: 2026-07-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal