odin-foundation 1.2.1 → 1.2.2
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 +201 -45
- data/lib/odin/utils/security_limits.rb +26 -0
- data/lib/odin/version.rb +1 -1
- 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: 7941332bc111a6e015c111bdacc0112a4de7158790df8dc5795846261d63c90f
|
|
4
|
+
data.tar.gz: 2925314fb92f73c7a2f83cfde86a0975a619c9ac9f88186aeffbcfdb29f2c587
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 07ebdd4410ca462bcc14fb2095ac1302b8aecbe06a32e069bdd9c2bd3ce08afdf0fb5653f319d347fcdbcfd59a5b8338e28b718e4eb9e77dc42c145b53e586c6
|
|
7
|
+
data.tar.gz: 67dad1314b2884092d7eafa3667e4815a2469ff94f9ec4c0870a49eb0c4701d28e94d32617e3d820d59327d6eb87583bdf7b0914011ea809ce657ff4bb5e3e4a
|
|
@@ -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,24 @@ module Odin
|
|
|
132
183
|
|
|
133
184
|
def initialize
|
|
134
185
|
@verb_registry = build_verb_registry
|
|
186
|
+
reset_guard!
|
|
187
|
+
end
|
|
188
|
+
|
|
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
|
+
def reset_guard!
|
|
192
|
+
@fuel_cap = Utils::SecurityLimits.max_transform_fuel
|
|
193
|
+
@timeout_ms = Utils::SecurityLimits.transform_timeout_ms
|
|
194
|
+
@max_expr_depth = Utils::SecurityLimits.max_expression_depth
|
|
195
|
+
@fuel_used = 0
|
|
196
|
+
@expr_depth = 0
|
|
197
|
+
@ops_since_clock = 0
|
|
198
|
+
@start_time = @timeout_ms.positive? ? monotonic_ms : 0
|
|
135
199
|
end
|
|
136
200
|
|
|
137
201
|
def execute(transform_def, source_data, import_resolver: nil)
|
|
202
|
+
reset_guard!
|
|
203
|
+
|
|
138
204
|
# Merge imported tables, constants, accumulators, and segments.
|
|
139
205
|
if import_resolver && transform_def.imports && !transform_def.imports.empty?
|
|
140
206
|
resolve_imports(transform_def, import_resolver)
|
|
@@ -159,23 +225,27 @@ module Odin
|
|
|
159
225
|
|
|
160
226
|
# 3. Process segments (multi-pass support)
|
|
161
227
|
output = {}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
228
|
+
begin
|
|
229
|
+
passes = transform_def.passes
|
|
230
|
+
if passes.empty?
|
|
231
|
+
# Single implicit pass
|
|
232
|
+
process_segment_list(transform_def.segments, source, context, output)
|
|
233
|
+
else
|
|
234
|
+
# Multi-pass: explicit passes first, then pass-0 (implicit)
|
|
235
|
+
all_passes = passes.include?(0) ? passes : passes + [0]
|
|
236
|
+
first_pass = true
|
|
237
|
+
all_passes.each do |pass_num|
|
|
238
|
+
unless first_pass
|
|
239
|
+
reset_non_persist_accumulators(context, transform_def.accumulators)
|
|
240
|
+
end
|
|
241
|
+
first_pass = false
|
|
175
242
|
|
|
176
|
-
|
|
177
|
-
|
|
243
|
+
pass_segments = transform_def.segments.select { |s| (s.pass || 0) == pass_num }
|
|
244
|
+
process_segment_list(pass_segments, source, context, output)
|
|
245
|
+
end
|
|
178
246
|
end
|
|
247
|
+
rescue TransformAbortError => e
|
|
248
|
+
return abort_result(e, output, context)
|
|
179
249
|
end
|
|
180
250
|
|
|
181
251
|
# 4. Apply confidential enforcement
|
|
@@ -229,44 +299,48 @@ module Odin
|
|
|
229
299
|
end
|
|
230
300
|
|
|
231
301
|
# Process each record/line
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
302
|
+
begin
|
|
303
|
+
lines = raw_input.split(/[\r\n]+/)
|
|
304
|
+
lines.each do |line|
|
|
305
|
+
next if line.strip.empty?
|
|
235
306
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
307
|
+
disc_value = extract_discriminator_value(line, disc, delimiter)
|
|
308
|
+
segment = segment_map[disc_value]
|
|
309
|
+
next unless segment
|
|
239
310
|
|
|
240
|
-
|
|
241
|
-
|
|
311
|
+
record_source = parse_record(line, source_format, delimiter)
|
|
312
|
+
record_output = {}
|
|
242
313
|
|
|
243
|
-
|
|
244
|
-
|
|
314
|
+
# Set the record as the current source for path resolution
|
|
315
|
+
context.source = record_source
|
|
245
316
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
317
|
+
# Process field mappings
|
|
318
|
+
segment.field_mappings.each do |mapping|
|
|
319
|
+
process_mapping(mapping, record_source, context, record_output)
|
|
320
|
+
end
|
|
250
321
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
322
|
+
# Process children
|
|
323
|
+
segment.children.each do |child|
|
|
324
|
+
process_segment(child, record_source, context, record_output)
|
|
325
|
+
end
|
|
255
326
|
|
|
256
|
-
|
|
257
|
-
|
|
327
|
+
# Merge into output
|
|
328
|
+
seg_name = segment.name
|
|
258
329
|
|
|
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 }
|
|
330
|
+
if segment.is_array
|
|
331
|
+
array_accumulators[seg_name] ||= []
|
|
332
|
+
array_accumulators[seg_name] << record_output
|
|
266
333
|
else
|
|
267
|
-
|
|
334
|
+
# Merge fields into existing segment object
|
|
335
|
+
if output[seg_name].is_a?(Hash)
|
|
336
|
+
record_output.each { |k, v| output[seg_name][k] = v }
|
|
337
|
+
else
|
|
338
|
+
output[seg_name] = record_output
|
|
339
|
+
end
|
|
268
340
|
end
|
|
269
341
|
end
|
|
342
|
+
rescue TransformAbortError => e
|
|
343
|
+
return abort_result(e, output, context)
|
|
270
344
|
end
|
|
271
345
|
|
|
272
346
|
# Merge array accumulators into output in segment order
|
|
@@ -391,9 +465,84 @@ module Odin
|
|
|
391
465
|
end
|
|
392
466
|
end
|
|
393
467
|
|
|
468
|
+
# ── Execution Guard ──
|
|
469
|
+
|
|
470
|
+
# Surface a guard abort as a failed result; the abort is never raised past
|
|
471
|
+
# the execute boundary. The guard error joins context.errors so the result
|
|
472
|
+
# reports failure.
|
|
473
|
+
def abort_result(err, output, context)
|
|
474
|
+
context.errors << err.transform_error
|
|
475
|
+
plain_output = deep_to_ruby(output)
|
|
476
|
+
TransformResult.new(output: plain_output, formatted: "", errors: context.errors, warnings: context.warnings)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Monotonic clock in milliseconds for the wall-clock guard.
|
|
480
|
+
def monotonic_ms
|
|
481
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Charge fuel and, at a coarse interval, the wall clock. Both are no-ops
|
|
485
|
+
# unless their cap is set (> 0), so unbounded transforms pay nothing.
|
|
486
|
+
def charge(units)
|
|
487
|
+
if @fuel_cap.positive?
|
|
488
|
+
@fuel_used += units
|
|
489
|
+
raise TransformAbortError.new(self.class.budget_exceeded_error(@fuel_cap)) if @fuel_used > @fuel_cap
|
|
490
|
+
end
|
|
491
|
+
return unless @timeout_ms.positive?
|
|
492
|
+
|
|
493
|
+
@ops_since_clock += units
|
|
494
|
+
return if @ops_since_clock < CLOCK_CHECK_INTERVAL
|
|
495
|
+
|
|
496
|
+
@ops_since_clock = 0
|
|
497
|
+
return unless monotonic_ms - @start_time > @timeout_ms
|
|
498
|
+
|
|
499
|
+
raise TransformAbortError.new(self.class.timeout_exceeded_error(@timeout_ms))
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Charge width for a verb doing O(n)/O(n log n) work over an array argument,
|
|
503
|
+
# so large-array work cannot escape the budget at a flat unit. Runs when
|
|
504
|
+
# fuel OR timeout is set, so a timeout-only host can interrupt wide verbs.
|
|
505
|
+
def charge_verb_width(verb, args)
|
|
506
|
+
return if @fuel_cap <= 0 && @timeout_ms <= 0
|
|
507
|
+
|
|
508
|
+
n = first_array_width(args)
|
|
509
|
+
return if n <= 0
|
|
510
|
+
|
|
511
|
+
if SORT_VERBS.include?(verb)
|
|
512
|
+
charge(n * Math.log2([n, 2].max).ceil)
|
|
513
|
+
elsif WIDTH_VERBS.include?(verb)
|
|
514
|
+
charge(n)
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Width of the first array-typed argument, or 0 if none.
|
|
519
|
+
def first_array_width(args)
|
|
520
|
+
args.each do |arg|
|
|
521
|
+
return arg.value.length if arg.is_a?(Types::DynValue) && arg.array?
|
|
522
|
+
end
|
|
523
|
+
0
|
|
524
|
+
end
|
|
525
|
+
|
|
394
526
|
# ── Expression Evaluation ──
|
|
395
527
|
|
|
528
|
+
# Guard boundary: enforce depth, charge one base unit of fuel, and batch the
|
|
529
|
+
# wall-clock check before delegating to the evaluator. All evaluation paths
|
|
530
|
+
# funnel through here, so charging stays concentrated at this single point.
|
|
396
531
|
def evaluate(expr, context)
|
|
532
|
+
@expr_depth += 1
|
|
533
|
+
if @expr_depth > @max_expr_depth
|
|
534
|
+
@expr_depth -= 1
|
|
535
|
+
raise TransformAbortError.new(self.class.expression_depth_exceeded_error(@max_expr_depth))
|
|
536
|
+
end
|
|
537
|
+
charge(1)
|
|
538
|
+
begin
|
|
539
|
+
evaluate_inner(expr, context)
|
|
540
|
+
ensure
|
|
541
|
+
@expr_depth -= 1
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def evaluate_inner(expr, context)
|
|
397
546
|
case expr
|
|
398
547
|
when LiteralExpr
|
|
399
548
|
val = expr.value
|
|
@@ -1111,6 +1260,8 @@ module Odin
|
|
|
1111
1260
|
if target.start_with?("_")
|
|
1112
1261
|
begin
|
|
1113
1262
|
evaluate(mapping.expression, context)
|
|
1263
|
+
rescue TransformAbortError
|
|
1264
|
+
raise
|
|
1114
1265
|
rescue StandardError => e
|
|
1115
1266
|
context.errors << TransformError.new(e.message)
|
|
1116
1267
|
end
|
|
@@ -1227,6 +1378,9 @@ module Odin
|
|
|
1227
1378
|
# Store DynValue directly to preserve type information (date, timestamp, etc.)
|
|
1228
1379
|
dv_val = val.is_a?(Types::DynValue) ? val : Types::DynValue.from_ruby(val)
|
|
1229
1380
|
set_path(output, target, dv_val)
|
|
1381
|
+
rescue TransformAbortError
|
|
1382
|
+
# Guard aborts are not downgraded by onError.
|
|
1383
|
+
raise
|
|
1230
1384
|
rescue CodedTransformError => e
|
|
1231
1385
|
# Coded errors carry a stable T-code; preserve it under fail/warn.
|
|
1232
1386
|
coded = e.transform_error
|
|
@@ -1555,6 +1709,7 @@ module Odin
|
|
|
1555
1709
|
end
|
|
1556
1710
|
val
|
|
1557
1711
|
end
|
|
1712
|
+
charge_verb_width(verb_name, evaluated_args)
|
|
1558
1713
|
|
|
1559
1714
|
# Special handling for accumulate/set
|
|
1560
1715
|
case verb_name
|
|
@@ -1589,6 +1744,7 @@ module Odin
|
|
|
1589
1744
|
|
|
1590
1745
|
# Eager evaluation for all other verbs
|
|
1591
1746
|
evaluated_args = args.map { |arg| evaluate(arg, context) }
|
|
1747
|
+
charge_verb_width(verb_name, evaluated_args)
|
|
1592
1748
|
|
|
1593
1749
|
# Special handling for accumulate/set
|
|
1594
1750
|
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
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.2
|
|
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
|