philiprehberger-safe_exec 0.1.0
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 +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE +21 -0
- data/README.md +109 -0
- data/lib/philiprehberger/safe_exec/evaluator.rb +120 -0
- data/lib/philiprehberger/safe_exec/parser.rb +171 -0
- data/lib/philiprehberger/safe_exec/tokenizer.rb +55 -0
- data/lib/philiprehberger/safe_exec/version.rb +7 -0
- data/lib/philiprehberger/safe_exec.rb +47 -0
- metadata +58 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 83e5765edb1f180e4207173f2c002a3a35daefadb3e6e96c99aba519ade19914
|
|
4
|
+
data.tar.gz: c3137cd1943bbd2e2fbff2efd618ac315c36be91b7861d56825dd423ee461778
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 28a46f5aeb95d0d913377c5562bfb4c5351627bee26b50fae4d2fb636791f517d3cfaf0f28c545da4905747912bf8afc5f3f152709eb6d0ca71ce02ce4cfcc31
|
|
7
|
+
data.tar.gz: dfcc2017095f5acbf78d495171daddaab7af0128563c4cc232eea6813898d2fe679c56017ed842c287dd17103cd6cacf97433ae9572cb64a44cc9c0b9e98fcd1
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this gem will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-03-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Initial release
|
|
14
|
+
- Arithmetic operations: addition, subtraction, multiplication, division
|
|
15
|
+
- Comparison operators: ==, !=, >, <, >=, <=
|
|
16
|
+
- Boolean operators: &&, ||, !
|
|
17
|
+
- String concatenation via + operator
|
|
18
|
+
- Hash and array access via bracket notation and dot notation
|
|
19
|
+
- Context variable bindings
|
|
20
|
+
- Timeout support for runaway expressions
|
|
21
|
+
- Custom tokenizer and recursive descent parser (no eval, send, or method_missing)
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 philiprehberger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# philiprehberger-safe_exec
|
|
2
|
+
|
|
3
|
+
[](https://github.com/philiprehberger/rb-safe-exec/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/philiprehberger-safe_exec)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Sandboxed expression evaluator with whitelisted operations
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Ruby >= 3.1
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
Add to your Gemfile:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "philiprehberger-safe_exec"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or install directly:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
gem install philiprehberger-safe_exec
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
require "philiprehberger/safe_exec"
|
|
31
|
+
|
|
32
|
+
Philiprehberger::SafeExec.evaluate('2 + 3 * 4') # => 14
|
|
33
|
+
Philiprehberger::SafeExec.evaluate('(2 + 3) * 4') # => 20
|
|
34
|
+
Philiprehberger::SafeExec.evaluate('price * 1.08', { price: 100 }) # => 108.0
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Arithmetic
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
Philiprehberger::SafeExec.evaluate('10 + 5') # => 15
|
|
41
|
+
Philiprehberger::SafeExec.evaluate('10 - 5') # => 5
|
|
42
|
+
Philiprehberger::SafeExec.evaluate('10 * 5') # => 50
|
|
43
|
+
Philiprehberger::SafeExec.evaluate('10 / 3') # => 3 (integer division)
|
|
44
|
+
Philiprehberger::SafeExec.evaluate('10.0 / 3') # => 3.333...
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Comparisons and Booleans
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
Philiprehberger::SafeExec.evaluate('5 > 3') # => true
|
|
51
|
+
Philiprehberger::SafeExec.evaluate('5 == 5') # => true
|
|
52
|
+
Philiprehberger::SafeExec.evaluate('true && false') # => false
|
|
53
|
+
Philiprehberger::SafeExec.evaluate('!false || true') # => true
|
|
54
|
+
Philiprehberger::SafeExec.evaluate('age >= 18 && age < 65', { age: 25 }) # => true
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### String Operations
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
Philiprehberger::SafeExec.evaluate("'hello' + ' ' + 'world'") # => "hello world"
|
|
61
|
+
Philiprehberger::SafeExec.evaluate("name == 'Alice'", { name: 'Alice' }) # => true
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Hash and Array Access
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
context = { items: [10, 20, 30], user: { 'name' => 'Alice', 'role' => 'admin' } }
|
|
68
|
+
|
|
69
|
+
Philiprehberger::SafeExec.evaluate('items[0]', context) # => 10
|
|
70
|
+
Philiprehberger::SafeExec.evaluate("user['name']", context) # => "Alice"
|
|
71
|
+
Philiprehberger::SafeExec.evaluate('user.role', context) # => "admin"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Timeout
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
Philiprehberger::SafeExec.evaluate('1 + 1', {}, timeout: 2)
|
|
78
|
+
# Raises Philiprehberger::SafeExec::TimeoutError if evaluation exceeds 2 seconds
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## API
|
|
82
|
+
|
|
83
|
+
| Method | Description |
|
|
84
|
+
|--------|-------------|
|
|
85
|
+
| `SafeExec.evaluate(expr, context, timeout:)` | Evaluate an expression with context variables and optional timeout |
|
|
86
|
+
|
|
87
|
+
### Supported Operations
|
|
88
|
+
|
|
89
|
+
| Category | Operations |
|
|
90
|
+
|----------|-----------|
|
|
91
|
+
| Arithmetic | `+`, `-`, `*`, `/` |
|
|
92
|
+
| Comparison | `==`, `!=`, `>`, `<`, `>=`, `<=` |
|
|
93
|
+
| Boolean | `&&`, `\|\|`, `!` |
|
|
94
|
+
| String | concatenation via `+`, comparison |
|
|
95
|
+
| Access | `array[index]`, `hash['key']`, `hash.key` |
|
|
96
|
+
| Literals | integers, floats, strings, booleans, nil |
|
|
97
|
+
| Grouping | parentheses `()` |
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
bundle install
|
|
103
|
+
bundle exec rspec # Run tests
|
|
104
|
+
bundle exec rubocop # Check code style
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module SafeExec
|
|
5
|
+
# Evaluates an AST node tree against a context hash
|
|
6
|
+
class Evaluator
|
|
7
|
+
# @param context [Hash] variable bindings for evaluation
|
|
8
|
+
def initialize(context = {})
|
|
9
|
+
@context = normalize_context(context)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Evaluate an AST node
|
|
13
|
+
#
|
|
14
|
+
# @param node [Hash] the AST node
|
|
15
|
+
# @return [Object] the evaluation result
|
|
16
|
+
# @raise [Philiprehberger::SafeExec::Error] on evaluation errors
|
|
17
|
+
def evaluate(node)
|
|
18
|
+
case node[:type]
|
|
19
|
+
when :literal then node[:value]
|
|
20
|
+
when :identifier then resolve_identifier(node[:name])
|
|
21
|
+
when :binary then evaluate_binary(node)
|
|
22
|
+
when :comparison then evaluate_comparison(node)
|
|
23
|
+
when :and then evaluate(node[:left]) && evaluate(node[:right])
|
|
24
|
+
when :or then evaluate(node[:left]) || evaluate(node[:right])
|
|
25
|
+
when :not then !evaluate(node[:operand])
|
|
26
|
+
when :negate then -evaluate(node[:operand])
|
|
27
|
+
when :index then evaluate_index(node)
|
|
28
|
+
when :property then evaluate_property(node)
|
|
29
|
+
else raise Error, "unknown node type: #{node[:type]}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def normalize_context(context)
|
|
36
|
+
context.transform_keys(&:to_s)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def resolve_identifier(name)
|
|
40
|
+
raise Error, "undefined variable: #{name}" unless @context.key?(name)
|
|
41
|
+
|
|
42
|
+
@context[name]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def evaluate_binary(node)
|
|
46
|
+
left = evaluate(node[:left])
|
|
47
|
+
right = evaluate(node[:right])
|
|
48
|
+
|
|
49
|
+
case node[:op]
|
|
50
|
+
when '+' then evaluate_add(left, right)
|
|
51
|
+
when '-' then left - right
|
|
52
|
+
when '*' then left * right
|
|
53
|
+
when '/' then evaluate_divide(left, right)
|
|
54
|
+
else raise Error, "unknown operator: #{node[:op]}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def evaluate_add(left, right)
|
|
59
|
+
if left.is_a?(String) || right.is_a?(String)
|
|
60
|
+
left.to_s + right.to_s
|
|
61
|
+
else
|
|
62
|
+
left + right
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def evaluate_divide(left, right)
|
|
67
|
+
raise Error, 'division by zero' if right.is_a?(Numeric) && right.zero?
|
|
68
|
+
|
|
69
|
+
if left.is_a?(Integer) && right.is_a?(Integer)
|
|
70
|
+
left / right
|
|
71
|
+
else
|
|
72
|
+
left.to_f / right.to_f
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def evaluate_comparison(node)
|
|
77
|
+
left = evaluate(node[:left])
|
|
78
|
+
right = evaluate(node[:right])
|
|
79
|
+
|
|
80
|
+
case node[:op]
|
|
81
|
+
when '==' then left == right
|
|
82
|
+
when '!=' then left != right
|
|
83
|
+
when '>' then left > right
|
|
84
|
+
when '<' then left < right
|
|
85
|
+
when '>=' then left >= right
|
|
86
|
+
when '<=' then left <= right
|
|
87
|
+
else raise Error, "unknown comparison: #{node[:op]}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def evaluate_index(node)
|
|
92
|
+
object = evaluate(node[:object])
|
|
93
|
+
key = evaluate(node[:key])
|
|
94
|
+
|
|
95
|
+
case object
|
|
96
|
+
when Array
|
|
97
|
+
raise Error, "array index must be an integer, got #{key.class}" unless key.is_a?(Integer)
|
|
98
|
+
|
|
99
|
+
object[key]
|
|
100
|
+
when Hash
|
|
101
|
+
object[key] || object[key.to_s] || object[key.to_sym]
|
|
102
|
+
else
|
|
103
|
+
raise Error, "cannot index into #{object.class}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def evaluate_property(node)
|
|
108
|
+
object = evaluate(node[:object])
|
|
109
|
+
name = node[:name]
|
|
110
|
+
|
|
111
|
+
case object
|
|
112
|
+
when Hash
|
|
113
|
+
object[name] || object[name.to_s] || object[name.to_sym]
|
|
114
|
+
else
|
|
115
|
+
raise Error, "cannot access property '#{name}' on #{object.class}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module SafeExec
|
|
5
|
+
# Recursive descent parser that builds an AST from tokens
|
|
6
|
+
class Parser
|
|
7
|
+
def initialize(tokens)
|
|
8
|
+
@tokens = tokens
|
|
9
|
+
@pos = 0
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Parse the token stream into an AST node
|
|
13
|
+
#
|
|
14
|
+
# @return [Hash] the AST node
|
|
15
|
+
# @raise [Philiprehberger::SafeExec::Error] on parse errors
|
|
16
|
+
def parse
|
|
17
|
+
node = parse_or
|
|
18
|
+
raise Error, "unexpected token: #{current&.value}" if current
|
|
19
|
+
|
|
20
|
+
node
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def current
|
|
26
|
+
@tokens[@pos]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def advance
|
|
30
|
+
token = @tokens[@pos]
|
|
31
|
+
@pos += 1
|
|
32
|
+
token
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def expect(type)
|
|
36
|
+
token = advance
|
|
37
|
+
raise Error, "expected #{type}, got #{token&.type || 'end of input'}" unless token&.type == type
|
|
38
|
+
|
|
39
|
+
token
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def parse_or
|
|
43
|
+
left = parse_and
|
|
44
|
+
while current&.type == :operator && current.value == '||'
|
|
45
|
+
advance
|
|
46
|
+
right = parse_and
|
|
47
|
+
left = { type: :or, left: left, right: right }
|
|
48
|
+
end
|
|
49
|
+
left
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def parse_and
|
|
53
|
+
left = parse_equality
|
|
54
|
+
while current&.type == :operator && current.value == '&&'
|
|
55
|
+
advance
|
|
56
|
+
right = parse_equality
|
|
57
|
+
left = { type: :and, left: left, right: right }
|
|
58
|
+
end
|
|
59
|
+
left
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse_equality
|
|
63
|
+
left = parse_comparison
|
|
64
|
+
while current&.type == :operator && %w[== !=].include?(current.value)
|
|
65
|
+
op = advance.value
|
|
66
|
+
right = parse_comparison
|
|
67
|
+
left = { type: :comparison, op: op, left: left, right: right }
|
|
68
|
+
end
|
|
69
|
+
left
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse_comparison
|
|
73
|
+
left = parse_addition
|
|
74
|
+
while current&.type == :operator && %w[> < >= <=].include?(current.value)
|
|
75
|
+
op = advance.value
|
|
76
|
+
right = parse_addition
|
|
77
|
+
left = { type: :comparison, op: op, left: left, right: right }
|
|
78
|
+
end
|
|
79
|
+
left
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def parse_addition
|
|
83
|
+
left = parse_multiplication
|
|
84
|
+
while current&.type == :operator && %w[+ -].include?(current.value)
|
|
85
|
+
op = advance.value
|
|
86
|
+
right = parse_multiplication
|
|
87
|
+
left = { type: :binary, op: op, left: left, right: right }
|
|
88
|
+
end
|
|
89
|
+
left
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parse_multiplication
|
|
93
|
+
left = parse_unary
|
|
94
|
+
while current&.type == :operator && %w[* /].include?(current.value)
|
|
95
|
+
op = advance.value
|
|
96
|
+
right = parse_unary
|
|
97
|
+
left = { type: :binary, op: op, left: left, right: right }
|
|
98
|
+
end
|
|
99
|
+
left
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parse_unary
|
|
103
|
+
if current&.type == :operator && current.value == '!'
|
|
104
|
+
advance
|
|
105
|
+
operand = parse_unary
|
|
106
|
+
return { type: :not, operand: operand }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if current&.type == :operator && current.value == '-'
|
|
110
|
+
advance
|
|
111
|
+
operand = parse_unary
|
|
112
|
+
return { type: :negate, operand: operand }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
parse_access
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def parse_access
|
|
119
|
+
node = parse_primary
|
|
120
|
+
|
|
121
|
+
while current
|
|
122
|
+
if current.type == :lbracket
|
|
123
|
+
advance
|
|
124
|
+
index = parse_or
|
|
125
|
+
expect(:rbracket)
|
|
126
|
+
node = { type: :index, object: node, key: index }
|
|
127
|
+
elsif current.type == :dot
|
|
128
|
+
advance
|
|
129
|
+
property = expect(:identifier)
|
|
130
|
+
node = { type: :property, object: node, name: property.value }
|
|
131
|
+
else
|
|
132
|
+
break
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
node
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def parse_primary
|
|
140
|
+
token = current
|
|
141
|
+
raise Error, 'unexpected end of expression' unless token
|
|
142
|
+
|
|
143
|
+
case token.type
|
|
144
|
+
when :number
|
|
145
|
+
advance
|
|
146
|
+
value = token.value.include?('.') ? token.value.to_f : token.value.to_i
|
|
147
|
+
{ type: :literal, value: value }
|
|
148
|
+
when :string
|
|
149
|
+
advance
|
|
150
|
+
{ type: :literal, value: token.value[1..-2] }
|
|
151
|
+
when :boolean
|
|
152
|
+
advance
|
|
153
|
+
{ type: :literal, value: token.value == 'true' }
|
|
154
|
+
when :null
|
|
155
|
+
advance
|
|
156
|
+
{ type: :literal, value: nil }
|
|
157
|
+
when :identifier
|
|
158
|
+
advance
|
|
159
|
+
{ type: :identifier, name: token.value }
|
|
160
|
+
when :lparen
|
|
161
|
+
advance
|
|
162
|
+
node = parse_or
|
|
163
|
+
expect(:rparen)
|
|
164
|
+
node
|
|
165
|
+
else
|
|
166
|
+
raise Error, "unexpected token: #{token.value}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module SafeExec
|
|
5
|
+
# Tokenizes expression strings into an array of typed tokens
|
|
6
|
+
module Tokenizer
|
|
7
|
+
TOKEN_PATTERNS = [
|
|
8
|
+
[:number, /\A-?\d+(?:\.\d+)?/],
|
|
9
|
+
[:string, /\A'[^']*'/],
|
|
10
|
+
[:string, /\A"[^"]*"/],
|
|
11
|
+
[:boolean, /\A(?:true|false)\b/],
|
|
12
|
+
[:null, /\Anil\b/],
|
|
13
|
+
[:operator, /\A(?:&&|\|\||[!=]=|>=|<=|[+\-*\/><!])/],
|
|
14
|
+
[:lparen, /\A\(/],
|
|
15
|
+
[:rparen, /\A\)/],
|
|
16
|
+
[:lbracket, /\A\[/],
|
|
17
|
+
[:rbracket, /\A\]/],
|
|
18
|
+
[:dot, /\A\./],
|
|
19
|
+
[:comma, /\A,/],
|
|
20
|
+
[:identifier, /\A[a-zA-Z_][a-zA-Z0-9_]*/],
|
|
21
|
+
[:whitespace, /\A\s+/]
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
Token = Struct.new(:type, :value, keyword_init: true)
|
|
25
|
+
|
|
26
|
+
# Tokenize an expression string
|
|
27
|
+
#
|
|
28
|
+
# @param input [String] the expression to tokenize
|
|
29
|
+
# @return [Array<Token>] the token list
|
|
30
|
+
# @raise [Philiprehberger::SafeExec::Error] on unexpected characters
|
|
31
|
+
def self.tokenize(input)
|
|
32
|
+
tokens = []
|
|
33
|
+
pos = 0
|
|
34
|
+
|
|
35
|
+
while pos < input.length
|
|
36
|
+
matched = false
|
|
37
|
+
|
|
38
|
+
TOKEN_PATTERNS.each do |type, pattern|
|
|
39
|
+
match = input[pos..].match(pattern)
|
|
40
|
+
next unless match
|
|
41
|
+
|
|
42
|
+
tokens << Token.new(type: type, value: match[0]) unless type == :whitespace
|
|
43
|
+
pos += match[0].length
|
|
44
|
+
matched = true
|
|
45
|
+
break
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
raise Error, "unexpected character at position #{pos}: '#{input[pos]}'" unless matched
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
tokens
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'safe_exec/version'
|
|
4
|
+
require_relative 'safe_exec/tokenizer'
|
|
5
|
+
require_relative 'safe_exec/parser'
|
|
6
|
+
require_relative 'safe_exec/evaluator'
|
|
7
|
+
|
|
8
|
+
module Philiprehberger
|
|
9
|
+
module SafeExec
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
# Raised when evaluation exceeds the timeout
|
|
13
|
+
class TimeoutError < Error; end
|
|
14
|
+
|
|
15
|
+
DEFAULT_TIMEOUT = 5
|
|
16
|
+
|
|
17
|
+
# Evaluate a sandboxed expression with an optional context
|
|
18
|
+
#
|
|
19
|
+
# @param expr [String] the expression to evaluate
|
|
20
|
+
# @param context [Hash] variable bindings
|
|
21
|
+
# @param timeout [Numeric] maximum evaluation time in seconds
|
|
22
|
+
# @return [Object] the evaluation result
|
|
23
|
+
# @raise [Error] on parse or evaluation errors
|
|
24
|
+
# @raise [TimeoutError] if evaluation exceeds the timeout
|
|
25
|
+
def self.evaluate(expr, context = {}, timeout: DEFAULT_TIMEOUT)
|
|
26
|
+
result = nil
|
|
27
|
+
error = nil
|
|
28
|
+
|
|
29
|
+
thread = Thread.new do
|
|
30
|
+
tokens = Tokenizer.tokenize(expr)
|
|
31
|
+
ast = Parser.new(tokens).parse
|
|
32
|
+
result = Evaluator.new(context).evaluate(ast)
|
|
33
|
+
rescue Error => e
|
|
34
|
+
error = e
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
unless thread.join(timeout)
|
|
38
|
+
thread.kill
|
|
39
|
+
raise TimeoutError, "expression evaluation timed out after #{timeout} seconds"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
raise error if error
|
|
43
|
+
|
|
44
|
+
result
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: philiprehberger-safe_exec
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Philip Rehberger
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-22 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Safely evaluate arithmetic, comparison, and boolean expressions from
|
|
14
|
+
untrusted input. Uses a custom parser with no eval, send, or method_missing. Includes
|
|
15
|
+
timeout support.
|
|
16
|
+
email:
|
|
17
|
+
- me@philiprehberger.com
|
|
18
|
+
executables: []
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- CHANGELOG.md
|
|
23
|
+
- LICENSE
|
|
24
|
+
- README.md
|
|
25
|
+
- lib/philiprehberger/safe_exec.rb
|
|
26
|
+
- lib/philiprehberger/safe_exec/evaluator.rb
|
|
27
|
+
- lib/philiprehberger/safe_exec/parser.rb
|
|
28
|
+
- lib/philiprehberger/safe_exec/tokenizer.rb
|
|
29
|
+
- lib/philiprehberger/safe_exec/version.rb
|
|
30
|
+
homepage: https://github.com/philiprehberger/rb-safe-exec
|
|
31
|
+
licenses:
|
|
32
|
+
- MIT
|
|
33
|
+
metadata:
|
|
34
|
+
homepage_uri: https://github.com/philiprehberger/rb-safe-exec
|
|
35
|
+
source_code_uri: https://github.com/philiprehberger/rb-safe-exec
|
|
36
|
+
changelog_uri: https://github.com/philiprehberger/rb-safe-exec/blob/main/CHANGELOG.md
|
|
37
|
+
bug_tracker_uri: https://github.com/philiprehberger/rb-safe-exec/issues
|
|
38
|
+
rubygems_mfa_required: 'true'
|
|
39
|
+
post_install_message:
|
|
40
|
+
rdoc_options: []
|
|
41
|
+
require_paths:
|
|
42
|
+
- lib
|
|
43
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: 3.1.0
|
|
48
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '0'
|
|
53
|
+
requirements: []
|
|
54
|
+
rubygems_version: 3.5.22
|
|
55
|
+
signing_key:
|
|
56
|
+
specification_version: 4
|
|
57
|
+
summary: Sandboxed expression evaluator with whitelisted operations
|
|
58
|
+
test_files: []
|