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 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
+ [![Tests](https://github.com/philiprehberger/rb-safe-exec/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-safe-exec/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/philiprehberger-safe_exec.svg)](https://rubygems.org/gems/philiprehberger-safe_exec)
5
+ [![License](https://img.shields.io/github/license/philiprehberger/rb-safe-exec)](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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module SafeExec
5
+ VERSION = '0.1.0'
6
+ end
7
+ 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: []