cel 0.3.1 → 0.4.1

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: 82fcb37e45c0553a7c073b58e4c6b7203cc50bd186419724d72f5f1b0217e3f5
4
- data.tar.gz: 44170fc7001634abc19860db8dcbc8c05ae8974716ad80dd28c4316dc0cec7f3
3
+ metadata.gz: e4d35d9012196d61497f40e35c8e9dd9b1a9d7e1e922078a414cd574eacc439a
4
+ data.tar.gz: f2a0e48bc07e0c0b5eb23c7ea6d709a1b56fff3400b51204d26c3031f11db696
5
5
  SHA512:
6
- metadata.gz: 593ec20b7001fe593d6df79928ca8411327251fff2405491eb33642299cf6bc09d255d0088fdb6a1deda77eabccf8bcce229ad5038f68ee932f9c10ef162cdc0
7
- data.tar.gz: 55eb21528a3ff994f5fc013ca49ded2b7cb0aaf9d5d434c671fc0cd534ed0119269c1e2bf8b5fe9e44ba1f1e05f51d30f5fd84abd1f44d740898dd971b341ece
6
+ metadata.gz: d39c028b26513ed27749f5bd7c5cfa57281b0433a5b5984418d657fd61441122e4cddae1942cdb7b3e3c9aeb0979d7387ca5d7444cea0afd04477615278acbfa
7
+ data.tar.gz: 442da2f399685e77bee5f743c693f0ef0fe9b333dfea333f61b17f8419a6bf7752aca14e0c5211ff0a8a526bc36a9a575a9e0cac53190f47e3af0b54097c26de
data/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.1] - 2025-11-25
4
+
5
+ ### Improvements
6
+
7
+ * Literal class can now mark which methods are CEL directives, the remainder being lib private helpers.
8
+ * `cel` is now ractor compatible.
9
+ * Documentation on how to support abstract types has been added.
10
+
11
+ ### Security
12
+
13
+ A remote execution attack vector has been fixed, which allowed executing arbitrary Ruby code within a CEL expression when calling functions on a variable declared as a CEL map. Example:
14
+
15
+ ```ruby
16
+ env = Cel::Environment.new(declarations: { webhook: :map })
17
+ env.evaluate("webhook.payload.send('eval', 'File.write(\"test.txt\", \"Hello, world!\")')", webhook: { payload: {} })
18
+ ```
19
+
20
+ ## [0.4.0] - 2025-11-18
21
+
22
+ ### Features
23
+
24
+ `cel-ruby` supports the defined set of [CEL extensions](https://github.com/google/cel-go/blob/master/ext/README.md): math, string, bind and encoders.
25
+
26
+ ```ruby
27
+ Cel::Environment.new.evaluate("math.bitShiftLeft(1, 2) ") #=> returns 4
28
+ ```
29
+
30
+ ### Improvements
31
+
32
+ When the `tzinfo-data` gem is loaded, expressions will support human-friendly timezones, such as "US/Central", for timestamps.
33
+
3
34
  ## [0.3.1] - 2025-08-01
4
35
 
5
36
  ### Improvements
@@ -9,7 +40,7 @@
9
40
 
10
41
  ### Bugfixes
11
42
 
12
- * `has()` macro correctly works for mapsl
43
+ * `has()` macro correctly works for maps
13
44
 
14
45
  ## [0.3.0] - 2025-06-12
15
46
 
data/README.md CHANGED
@@ -131,14 +131,37 @@ env.evaluate("google.protobuf.Duration{seconds: 123}.seconds == 123") #=> true
131
131
  `cel-ruby` allows you to define custom functions to be used insde CEL expressions. While we **strongly** recommend usage of `Cel::Function` for defining them (due to the ability of them being used for checking), the only requirement is that the function object responds to `.call`:
132
132
 
133
133
  ```ruby
134
- env = environment(foo: Cel::Function(:int, :int, return_type: :int) { |a, b| a + b})
134
+ env = Cel::Environment.new(declarations: {foo: Cel::Function(:int, :int, return_type: :int) { |a, b| a + b }})
135
135
  env.evaluate("foo(2, 2)") #=> 4
136
136
 
137
137
  # this is also possible, just not as type-safe
138
- env2 = environment(foo: -> (a, b) { a + b})
138
+ env2 = Cel::Environment.new(declarations: {foo: ->(a, b) { a + b }})
139
139
  env2.evaluate("foo(2, 2)") #=> 4
140
140
  ```
141
141
 
142
+ ### Abstract types
143
+
144
+ `cel-ruby` supports defining abstract types, via the `Cel::AbstractType` class.
145
+
146
+ ```ruby
147
+ class Tuple
148
+ attr_reader :value
149
+ def initialize(ary)
150
+ @value = ary
151
+ end
152
+ end
153
+
154
+ tuple_type = Class.new(Cel::AbstractType) do
155
+ def convert(value)
156
+ Tuple.new(value)
157
+ end
158
+ end.new(:tuple, Cel::TYPES[:int], Cel::TYPES[:int])
159
+ env = Cel::Environment.new(declarations: { tuple: Cel::Function(:int, :int, return_type: tuple_type) {|a, b| [a, b] } })
160
+ env.check('tuple(1, 2)') #=> tuple type
161
+ res = env.evaluate('tuple(1, 2)') #=> Tuple instance
162
+ res.value #=> [1, 2]
163
+ ```
164
+
142
165
  ## Spec Coverage
143
166
 
144
167
  `cel` is tested against the conformance suite from the [cel-spec repository](https://github.com/google/cel-spec/tree/master/conformance), and supports all features from the language except:
@@ -185,7 +208,7 @@ The parser is based on the grammar defined in [cel-spec](https://github.com/goog
185
208
  Changes in the parser are therefore accomplished by modifying the `parser.ry` file and running:
186
209
 
187
210
  ```bash
188
- > bundle exec racc -o lib/cel/parser.rb lib/cel/parser.ry
211
+ > bundle exec racc -F -o lib/cel/parser.rb lib/cel/parser.ry
189
212
  ```
190
213
 
191
214
  ## Contributing
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ module CelMethods
5
+ def self.included(klass)
6
+ super
7
+ klass.class_eval do
8
+ @cel_methods = [] # rubocop:disable ThreadSafety/ClassInstanceVariable
9
+
10
+ extend ClassMethods
11
+ include InstanceMethods
12
+ end
13
+ end
14
+
15
+ module ClassMethods
16
+ attr_reader :cel_methods
17
+
18
+ def define_cel_method(meth, &blk)
19
+ Ractor.make_shareable(blk) if defined?(Ractor)
20
+ define_method(meth, &blk)
21
+
22
+ @cel_methods << meth # rubocop:disable ThreadSafety/ClassInstanceVariable
23
+ end
24
+
25
+ def inherited(klass)
26
+ super
27
+
28
+ klass.instance_variable_set(:@cel_methods, [])
29
+ end
30
+
31
+ def freeze(*a)
32
+ super
33
+ @cel_methods.freeze(*a) # rubocop:disable ThreadSafety/ClassInstanceVariable
34
+ end
35
+ end
36
+
37
+ module InstanceMethods
38
+ def cel_send(meth, *args)
39
+ meth = meth.to_sym
40
+ raise NoCelMethodError unless cel_method_defined?(meth)
41
+
42
+ public_send(meth, *args)
43
+ end
44
+
45
+ def cel_method_defined?(meth)
46
+ klass = self.class
47
+
48
+ while klass != Literal
49
+ return true if klass.cel_methods.include?(meth)
50
+
51
+ klass = klass.superclass
52
+ end
53
+
54
+ klass.cel_methods.include?(meth)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ class Bool < Literal
5
+ def initialize(value)
6
+ super(:bool, value)
7
+ end
8
+
9
+ (LOGICAL_OPERATORS - %w[== != in]).each do |op|
10
+ class_eval(<<-OUT, __FILE__, __LINE__ + 1)
11
+ define_cel_method(:#{op}) do |other|
12
+ return super(other) unless other.is_a?(Bool)
13
+
14
+ lhs = @value ? 1 : 0
15
+ rhs = other.value ? 1 : 0
16
+ lhs.__send__(__method__, rhs)
17
+ end
18
+ OUT
19
+ end
20
+
21
+ def !
22
+ Bool.cast(super)
23
+ end
24
+
25
+ TRUE_VALUE = new(true)
26
+ FALSE_VALUE = new(false)
27
+
28
+ def self.cast(val)
29
+ return val if val.is_a?(Bool)
30
+
31
+ val ? TRUE_VALUE : FALSE_VALUE
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ class Bytes < Literal
5
+ attr_reader :string
6
+
7
+ def initialize(value)
8
+ super(:bytes, value)
9
+ @string = value.pack("C*")
10
+ end
11
+
12
+ def to_ary
13
+ [self]
14
+ end
15
+
16
+ alias_method :to_ruby_type, :string
17
+
18
+ (LOGICAL_OPERATORS - %w[==]).each do |op|
19
+ class_eval(<<-OUT, __FILE__, __LINE__ + 1)
20
+ define_cel_method(:#{op}) do |other|
21
+ other.is_a?(Cel::Bytes) ? Bool.cast(@string.__send__(__method__, other.string)) : super(other)
22
+ end
23
+ OUT
24
+ end
25
+
26
+ %i[+ -].each do |op|
27
+ class_eval(<<-OUT, __FILE__, __LINE__ + 1)
28
+ define_cel_method(:#{op}) do |other|
29
+ Bytes.new(@value + other.value)
30
+ end
31
+ OUT
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ class Condition
5
+ attr_reader :if, :then, :else, :depth
6
+
7
+ def initialize(if_, then_, else_, depth: 1)
8
+ @if = if_
9
+ @then = then_
10
+ @else = else_
11
+ @depth = depth
12
+ end
13
+
14
+ def type
15
+ TYPES[:any]
16
+ end
17
+
18
+ def ==(other)
19
+ other.is_a?(Condition) && @if == other.if && @then == other.then && @else == other.else
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ class Duration < Literal
5
+ def initialize(value)
6
+ value =
7
+ case value
8
+ when ::String
9
+ init_from_string(value)
10
+ when Hash
11
+ seconds, nanos = value.values_at(:seconds, :nanos)
12
+ seconds ||= 0
13
+ nanos ||= 0
14
+ seconds + (nanos / 1_000_000_000.0)
15
+ else
16
+ value
17
+ end
18
+
19
+ raise EvaluateError, "out of range" unless (value * 1_000_000_000).between?(-MAX_INT, MAX_INT)
20
+
21
+ super(:duration, value)
22
+ end
23
+
24
+ def to_s
25
+ seconds = getSeconds
26
+ millis = getMilliseconds
27
+ millis.positive? ? "#{seconds}s#{millis}m" : "#{seconds}s"
28
+ end
29
+
30
+ define_cel_method(:+) do |other|
31
+ case other
32
+ when Cel::Duration
33
+ Cel::Duration.new(@value + other.value)
34
+ when Cel::Timestamp
35
+ Cel::Timestamp.new(other.value + @value)
36
+ end
37
+ end
38
+
39
+ define_cel_method(:-) do |other|
40
+ case other
41
+ when Cel::Duration
42
+ Cel::Duration.new(@value - other.value)
43
+ else
44
+ raise Error, "invalid operand #{other} for `-`"
45
+ end
46
+ end
47
+
48
+ LOGICAL_OPERATORS.each do |op|
49
+ class_eval(<<-OUT, __FILE__, __LINE__ + 1)
50
+ define_cel_method(:#{op}) do |other|
51
+ case other
52
+ when Cel::Literal
53
+ Bool.cast(super(other))
54
+ when Numeric
55
+ @value == other
56
+
57
+ else
58
+ super(other)
59
+ end
60
+ end
61
+ OUT
62
+ end
63
+
64
+ # Cel Functions
65
+
66
+ define_cel_method(:getHours) do
67
+ Cel::Number.new(:int, (getMinutes / 60).to_i)
68
+ end
69
+
70
+ define_cel_method(:getMinutes) do
71
+ Cel::Number.new(:int, (getSeconds / 60).to_i)
72
+ end
73
+
74
+ define_cel_method(:getSeconds) do
75
+ Cel::Number.new(:int, @value.divmod(1).first)
76
+ end
77
+
78
+ define_cel_method(:getMilliseconds) do
79
+ Cel::Number.new(:int, (@value.divmod(1).last * 1000).round)
80
+ end
81
+
82
+ def to_ruby_type
83
+ Protobuf.duration_class.new(seconds: getSeconds.value, nanos: getMilliseconds.value * 1_000_000_000.0)
84
+ end
85
+
86
+ private
87
+
88
+ def init_from_string(value)
89
+ seconds = 0
90
+ nanos = 0
91
+ value.scan(/([0-9]*(?:\.[0-9]*)?)([a-z]+)/) do |duration, units|
92
+ case units
93
+ when "h"
94
+ seconds += Cel.to_numeric(duration) * 60 * 60
95
+ when "m"
96
+ seconds += Cel.to_numeric(duration) * 60
97
+ when "s"
98
+ seconds += Cel.to_numeric(duration)
99
+ when "ms"
100
+ nanos += Cel.to_numeric(duration) * 1000 * 1000
101
+ when "us"
102
+ nanos += Cel.to_numeric(duration) * 1000
103
+ when "ns"
104
+ nanos += Cel.to_numeric(duration)
105
+ else
106
+ raise EvaluateError, "#{units} is unsupported"
107
+ end
108
+ end
109
+ duration = seconds + (nanos / 1_000_000_000.0)
110
+ duration = -duration if value.start_with?("-")
111
+ duration
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ class Function
5
+ attr_reader :label, :types, :type
6
+
7
+ def initialize(*types, label: nil, return_type: nil, &func)
8
+ unless func.nil?
9
+ types = Array.new(func.arity) { TYPES[:any] } if types.empty?
10
+ raise(Error, "number of arg types does not match number of yielded args") unless types.size == func.arity
11
+ end
12
+ @types = types.map { |typ| typ.is_a?(Type) ? typ : TYPES[typ] }
13
+ @type = if return_type.nil?
14
+ TYPES[:any]
15
+ else
16
+ return_type.is_a?(Type) ? return_type : TYPES[return_type]
17
+ end
18
+ @func = func
19
+ @label = label
20
+ end
21
+
22
+ def call(*args)
23
+ result = @func.call(*args)
24
+
25
+ @type.convert(result)
26
+ end
27
+ end
28
+
29
+ mod = self
30
+ mod.define_singleton_method(:Function) do |*args, **kwargs, &blk|
31
+ mod::Function.new(*args, **kwargs, &blk)
32
+ end
33
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ class Group
5
+ attr_reader :value
6
+
7
+ def initialize(value)
8
+ @value = value
9
+ end
10
+
11
+ def type
12
+ TYPES[:any]
13
+ end
14
+
15
+ def ==(other)
16
+ other.is_a?(Group) && @value == other.value
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module Cel
6
+ class Identifier < SimpleDelegator
7
+ attr_reader :id
8
+
9
+ attr_accessor :type
10
+
11
+ def initialize(identifier, package = nil)
12
+ @id = identifier
13
+ @type = TYPES[:any]
14
+ @package = package
15
+ super(@id)
16
+ end
17
+
18
+ def ==(other)
19
+ super || other.to_s == @id.to_s
20
+ end
21
+
22
+ def to_s
23
+ @id.to_s
24
+ end
25
+
26
+ alias_method :to_ruby_type, :to_s
27
+
28
+ def try_convert_to_proto_type
29
+ Protobuf.convert_to_proto_type(@id.to_s, @package)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ class Invoke
5
+ attr_accessor :var
6
+
7
+ attr_reader :func, :args, :depth
8
+
9
+ def self.new(func:, var: nil, args: nil, **rest)
10
+ Protobuf.try_invoke_from(var, func, args) || super
11
+ end
12
+
13
+ def type
14
+ TYPES[:any]
15
+ end
16
+
17
+ def initialize(func:, var: nil, args: nil, depth: 1, package: nil)
18
+ @var = var
19
+ @func = func.to_sym
20
+ @args = args
21
+ @package = package
22
+ @depth = depth
23
+ end
24
+
25
+ def ==(other)
26
+ case other
27
+ when Invoke
28
+ @var == other.var && @func == other.func && @args == other.args
29
+ when Array
30
+ [@var, @func, @args].compact == other
31
+ else
32
+ super
33
+ end
34
+ end
35
+
36
+ def to_s
37
+ if var
38
+ if indexing?
39
+ "#{var}[#{args}]"
40
+ else
41
+ "#{var}.#{func}#{"(#{args.map(&:to_s).join(", ")})" if args}"
42
+ end
43
+ else
44
+ "#{func}#{"(#{args.map(&:to_s).join(", ")})" if args}"
45
+ end
46
+ end
47
+
48
+ def try_convert_to_proto_type
49
+ return unless @var
50
+
51
+ proto_type = Protobuf.convert_to_proto_type(@var.to_s, @package)
52
+
53
+ return unless proto_type
54
+
55
+ return proto_type unless proto_type.const_defined?(@func)
56
+
57
+ proto_type.const_get(@func)
58
+ end
59
+
60
+ def indexing?
61
+ @func == :[]
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ class List < Literal
5
+ attr_reader :depth
6
+
7
+ def initialize(value, depth: 1)
8
+ value = value.map do |v|
9
+ Literal.to_cel_type(v)
10
+ end
11
+ super(TYPES[:list], value)
12
+ @depth = depth
13
+ end
14
+
15
+ define_cel_method(:[]) do |key|
16
+ case key
17
+ when Number
18
+ if key.type == :double
19
+ val = key.value
20
+
21
+ raise InvalidArgumentError, key unless (val % 1).zero?
22
+ end
23
+ end
24
+
25
+ super(key)
26
+ end
27
+
28
+ def to_ary
29
+ [self]
30
+ end
31
+
32
+ def to_ruby_type
33
+ value.map(&:to_ruby_type)
34
+ end
35
+
36
+ %i[+ -].each do |op|
37
+ class_eval(<<-OUT, __FILE__, __LINE__ + 1)
38
+ define_cel_method(:#{op}) do |other|
39
+ List.new(@value.send(__method__, other.value))
40
+ end
41
+ OUT
42
+ end
43
+
44
+ def by_max_depth
45
+ max = @value.max { |a1, a2| calc_depth(a1, 0) <=> calc_depth(a2, 0) }
46
+
47
+ # return the last value if all options have the same depth
48
+ return @value.last if (max == @value.first) && (calc_depth(max, 0) == calc_depth(@value.last, 0))
49
+
50
+ max
51
+ end
52
+
53
+ def to_s
54
+ "[#{@value.map(&:to_s).join(", ")}]"
55
+ end
56
+
57
+ private
58
+
59
+ def calc_depth(element, acc)
60
+ case element
61
+ when List
62
+ element.value.map { |el| calc_depth(el, acc + 1) }.max || (acc + 1)
63
+ when Map
64
+ element.value.map { |(_, el)| calc_depth(el, acc + 1) }.max || (acc + 1)
65
+ else
66
+ acc
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ class Literal < SimpleDelegator
5
+ include CelMethods
6
+
7
+ attr_reader :type, :value
8
+
9
+ class << self
10
+ def to_cel_type(val)
11
+ if val.is_a?(Protobuf.base_class) ||
12
+ val.is_a?(Struct) # no-protobuf mode
13
+ val = Protobuf.try_convert_from_wrapper(val)
14
+
15
+ return val if val.is_a?(Protobuf.base_class) # still
16
+ end
17
+
18
+ case val
19
+ when Literal, Identifier, Invoke, Operation, Condition, Group, # already cel
20
+ Protobuf.enum_class # already a usable class a la protobuf
21
+ val
22
+ when Protobuf.map_class
23
+ Map.new(val.to_h)
24
+ when ::String
25
+ String.new(val)
26
+ when ::Symbol
27
+ Identifier.new(val)
28
+ when ::Integer
29
+ Number.new(:int, val)
30
+ when ::Float, ::BigDecimal
31
+ Number.new(:double, val)
32
+ when ::Hash
33
+ Map.new(val)
34
+ when ::Array
35
+ List.new(val)
36
+ when true, false
37
+ Bool.cast(val)
38
+ when nil
39
+ Null::INSTANCE
40
+ when Time
41
+ Timestamp.new(val)
42
+ else
43
+ raise BindingError, "can't convert #{val} to CEL type"
44
+ end
45
+ end
46
+ end
47
+
48
+ def initialize(type, value)
49
+ @type = type.is_a?(Type) ? type : TYPES[type]
50
+ @value = value
51
+ super(value)
52
+ check
53
+ end
54
+
55
+ define_cel_method(:==) do |other|
56
+ case other
57
+ when Literal
58
+ @type == other.type && @value == other.value
59
+ else
60
+ @value == other
61
+ end
62
+ end
63
+
64
+ define_cel_method(:"!=") do |other|
65
+ !cel_send(:==, other)
66
+ end
67
+
68
+ private
69
+
70
+ def check; end
71
+
72
+ alias_method :to_ruby_type, :value
73
+ end
74
+ end