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 +4 -4
- data/lib/odin/transform/transform_engine.rb +206 -46
- data/lib/odin/utils/security_limits.rb +26 -0
- data/lib/odin/version.rb +1 -1
- data/lib/odin.rb +8 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 936f1721e9f83ea1cb3a0036d03d964354dde32d9b69012e52738a4be45869cf
|
|
4
|
+
data.tar.gz: e17ff98825a5bee6b86648ef70445cc9c2f5ea8b3c7c6a812072d13a968deb12
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
48
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
306
|
+
begin
|
|
307
|
+
lines = raw_input.split(/[\r\n]+/)
|
|
308
|
+
lines.each do |line|
|
|
309
|
+
next if line.strip.empty?
|
|
235
310
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
311
|
+
disc_value = extract_discriminator_value(line, disc, delimiter)
|
|
312
|
+
segment = segment_map[disc_value]
|
|
313
|
+
next unless segment
|
|
239
314
|
|
|
240
|
-
|
|
241
|
-
|
|
315
|
+
record_source = parse_record(line, source_format, delimiter)
|
|
316
|
+
record_output = {}
|
|
242
317
|
|
|
243
|
-
|
|
244
|
-
|
|
318
|
+
# Set the record as the current source for path resolution
|
|
319
|
+
context.source = record_source
|
|
245
320
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
326
|
+
# Process children
|
|
327
|
+
segment.children.each do |child|
|
|
328
|
+
process_segment(child, record_source, context, record_output)
|
|
329
|
+
end
|
|
255
330
|
|
|
256
|
-
|
|
257
|
-
|
|
331
|
+
# Merge into output
|
|
332
|
+
seg_name = segment.name
|
|
258
333
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-07-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bigdecimal
|