erblite 0.5.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.
@@ -0,0 +1,192 @@
1
+ # Copyright 2026 OpenC3, Inc.
2
+ # All Rights Reserved.
3
+ #
4
+ # This program is distributed in the hope that it will be useful,
5
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
6
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
7
+ # See LICENSE.md for more details.
8
+
9
+ # frozen_string_literal: true
10
+
11
+ module ERBLite
12
+ # The only file system access templates have (SPEC section 9.1). Every path
13
+ # is resolved against the real path of base_dir, symlinks included, and
14
+ # must stay inside it.
15
+ module Sandbox
16
+ module_function
17
+
18
+ def require_base(state)
19
+ return state.real_base if state.real_base
20
+
21
+ raise Error.new("SecurityError", "file access is disabled (no base_dir was given)")
22
+ end
23
+
24
+ def check_path_string(path)
25
+ unless path.is_a?(String)
26
+ raise Error.new("TypeError", "no implicit conversion of #{Builtins.tname(path)} into String")
27
+ end
28
+ raise Error.new("ArgumentError", "string contains null byte") if path.include?("\0")
29
+ end
30
+
31
+ def inside?(real, base)
32
+ return true if real == base
33
+
34
+ prefix = base.end_with?("/") ? base : "#{base}/"
35
+ real.start_with?(prefix)
36
+ end
37
+
38
+ # Lexically normalized absolute path for +path+ under +base+. A leading
39
+ # "~" is never expanded to a home directory.
40
+ def expand(path, base)
41
+ p = path.start_with?("~") ? "./#{path}" : path
42
+ File.expand_path(p, base)
43
+ end
44
+
45
+ # Resolves +path+ to a real path inside the sandbox or raises
46
+ # SecurityError. Missing trailing components are allowed (the caller
47
+ # reports IOError for them); the deepest existing ancestor is resolved
48
+ # with File.realpath so symlinks cannot escape.
49
+ def resolve(state, path)
50
+ base = require_base(state)
51
+ check_path_string(path)
52
+ full = expand(path, base)
53
+ rest = []
54
+ cur = full
55
+ until File.exist?(cur)
56
+ parent = File.dirname(cur)
57
+ break if parent == cur
58
+
59
+ rest.unshift(File.basename(cur))
60
+ cur = parent
61
+ end
62
+ real = begin
63
+ File.realpath(cur)
64
+ rescue SystemCallError
65
+ cur
66
+ end
67
+ real = File.join(real, *rest) unless rest.empty?
68
+ raise Error.new("SecurityError", "path is outside the sandbox: #{path}") unless inside?(real, base)
69
+
70
+ real
71
+ end
72
+
73
+ def read(state, path)
74
+ real = resolve(state, path)
75
+ raise Error.new("IOError", "No such file or directory - #{path}") unless File.exist?(real)
76
+ raise Error.new("IOError", "Is a directory - #{path}") if File.directory?(real)
77
+
78
+ File.binread(real).force_encoding(Encoding::UTF_8)
79
+ rescue SystemCallError => e
80
+ raise Error.new("IOError", "#{e.message} - #{path}")
81
+ end
82
+
83
+ def check(state, path, kind)
84
+ require_base(state)
85
+ check_path_string(path)
86
+ real = begin
87
+ resolve(state, path)
88
+ rescue Error => e
89
+ return false if e.type == "SecurityError"
90
+
91
+ raise
92
+ end
93
+ case kind
94
+ when :exist then File.exist?(real)
95
+ when :file then File.file?(real)
96
+ else File.directory?(real)
97
+ end
98
+ end
99
+
100
+ # Dir.glob with *, ** and ? only. Results are relative to base_dir and
101
+ # sorted.
102
+ def glob(state, pattern)
103
+ base = require_base(state)
104
+ check_path_string(pattern)
105
+ full = expand(pattern, base)
106
+ raise Error.new("SecurityError", "glob pattern is outside the sandbox: #{pattern}") unless inside?(full, base)
107
+
108
+ rel = full == base ? "" : full[(base.end_with?("/") ? base.length : base.length + 1)..]
109
+ segs = rel.split("/").reject(&:empty?)
110
+ out = []
111
+ walk(base, base, "", segs, out) unless segs.empty?
112
+ out.uniq.sort
113
+ end
114
+
115
+ def children(dir)
116
+ Dir.children(dir).sort
117
+ rescue SystemCallError
118
+ []
119
+ end
120
+
121
+ def safe_dir?(path, base)
122
+ File.directory?(path) && inside?(File.realpath(path), base)
123
+ rescue SystemCallError
124
+ false
125
+ end
126
+
127
+ def walk(base, dir, prefix, segs, out)
128
+ seg = segs[0]
129
+ last = segs.length == 1
130
+ join = ->(name) { prefix.empty? ? name : "#{prefix}/#{name}" }
131
+ if seg == "**" && !last
132
+ walk(base, dir, prefix, segs[1..], out)
133
+ children(dir).each do |name|
134
+ next if name.start_with?(".")
135
+
136
+ path = File.join(dir, name)
137
+ next if File.symlink?(path) || !safe_dir?(path, base)
138
+
139
+ walk(base, path, join.call(name), segs, out)
140
+ end
141
+ elsif seg.include?("*") || seg.include?("?")
142
+ children(dir).each do |name|
143
+ next if name.start_with?(".") && !seg.start_with?(".")
144
+ next unless wildcard_match?(seg, name)
145
+
146
+ path = File.join(dir, name)
147
+ if last
148
+ out << join.call(name)
149
+ elsif safe_dir?(path, base)
150
+ walk(base, path, join.call(name), segs[1..], out)
151
+ end
152
+ end
153
+ else
154
+ path = File.join(dir, seg)
155
+ return unless File.exist?(path) || File.symlink?(path)
156
+
157
+ if last
158
+ out << join.call(seg)
159
+ elsif safe_dir?(path, base)
160
+ walk(base, path, join.call(seg), segs[1..], out)
161
+ end
162
+ end
163
+ end
164
+
165
+ # Matches +name+ against a glob segment where * is any run of characters
166
+ # and ? is one character.
167
+ def wildcard_match?(pat, name)
168
+ p = 0
169
+ n = 0
170
+ star_p = nil
171
+ star_n = 0
172
+ while n < name.length
173
+ if p < pat.length && (pat[p] == "?" || pat[p] == name[n])
174
+ p += 1
175
+ n += 1
176
+ elsif p < pat.length && pat[p] == "*"
177
+ star_p = p
178
+ star_n = n
179
+ p += 1
180
+ elsif star_p
181
+ p = star_p + 1
182
+ star_n += 1
183
+ n = star_n
184
+ else
185
+ return false
186
+ end
187
+ end
188
+ p += 1 while p < pat.length && pat[p] == "*"
189
+ p == pat.length
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,139 @@
1
+ # Copyright 2026 OpenC3, Inc.
2
+ # All Rights Reserved.
3
+ #
4
+ # This program is distributed in the hope that it will be useful,
5
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
6
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
7
+ # See LICENSE.md for more details.
8
+
9
+ # frozen_string_literal: true
10
+
11
+ module ERBLite
12
+ # A built-in class or module constant (Integer, Comparable, Math, File, ...).
13
+ # Templates can only reach the instances created in Builtins::CONSTANTS.
14
+ class ClassValue
15
+ attr_reader :name
16
+
17
+ def initialize(name, is_module)
18
+ @name = name.freeze
19
+ @module = is_module
20
+ end
21
+
22
+ def module?
23
+ @module
24
+ end
25
+
26
+ def to_s
27
+ @name.dup
28
+ end
29
+
30
+ def inspect
31
+ @name.dup
32
+ end
33
+ end
34
+
35
+ # What an iterator method returns when it is called without a block.
36
+ # Iterating it calls method +meth+ on +recv+ with +args+ and a block.
37
+ class Enum
38
+ attr_reader :recv, :meth, :args
39
+
40
+ def initialize(recv, meth, args)
41
+ @recv = recv
42
+ @meth = meth
43
+ @args = args
44
+ end
45
+
46
+ def inspect
47
+ a = @args.empty? ? "" : "(#{@args.map { |x| Builtins.inspect(x) }.join(", ")})"
48
+ if @meth == "step" && arithmetic_sequence?
49
+ # Ruby returns an ArithmeticSequence here, which prints differently.
50
+ recv = Builtins.inspect(@recv)
51
+ return @recv.is_a?(Range) ? "((#{recv}).step#{a})" : "(#{recv}.step#{a})"
52
+ end
53
+ "#<Enumerator: #{Builtins.inspect(@recv)}:#{@meth}#{a}>"
54
+ end
55
+
56
+ def arithmetic_sequence?
57
+ num = ->(x) { x.is_a?(Integer) || x.is_a?(Float) }
58
+ return @args.all?(&num) if @recv.is_a?(Range)
59
+
60
+ (@recv.is_a?(Integer) || @recv.is_a?(Float)) && @args.all? { |x| num.call(x) || x.nil? }
61
+ end
62
+
63
+ def to_s
64
+ inspect
65
+ end
66
+ end
67
+
68
+ # Local variable scope. Each block call gets its own Env whose +blk+ is the
69
+ # UserBlock being run, so `break`/`next` know where to go.
70
+ class Env
71
+ attr_reader :vars, :parent, :blk
72
+
73
+ def initialize(parent, blk = nil)
74
+ @vars = {}
75
+ @parent = parent
76
+ @blk = blk
77
+ end
78
+ end
79
+
80
+ # A block written in the template ({ |x| ... } or do |x| ... end).
81
+ class UserBlock
82
+ attr_reader :next_tag
83
+
84
+ def initialize(interp, node, env, value_used = true)
85
+ @it = interp
86
+ @node = node
87
+ @env = env
88
+ @value_used = value_used
89
+ @next_tag = Object.new
90
+ end
91
+
92
+ def call(*vals)
93
+ @it.step!
94
+ e = Env.new(@env, self)
95
+ params = @node.params
96
+ vals = vals[0] if params.length > 1 && vals.length == 1 && vals[0].is_a?(Array)
97
+ params.each_with_index { |p, i| bind(p, vals[i], e) }
98
+ @it.want = @value_used
99
+ catch(@next_tag) { @it.ev(@node.body, e) }
100
+ end
101
+
102
+ private
103
+
104
+ def bind(param, value, env)
105
+ if param.is_a?(Array)
106
+ arr = value.is_a?(Array) ? value : [value]
107
+ param.each_with_index { |q, j| bind(q, arr[j], env) }
108
+ else
109
+ env.vars[param] = value
110
+ end
111
+ end
112
+ end
113
+
114
+ # &:name - calls method +name+ (through the allowlist) on the first value.
115
+ class SymBlock
116
+ def initialize(interp, name)
117
+ @it = interp
118
+ @name = name
119
+ end
120
+
121
+ def call(*vals)
122
+ @it.step!
123
+ raise Error.new("ArgumentError", "no receiver given") if vals.empty?
124
+
125
+ @it.call_method(vals[0], @name, vals[1..], nil)
126
+ end
127
+ end
128
+
129
+ # A block implemented by the interpreter itself (for loops, enumerators).
130
+ class NativeBlock
131
+ def initialize(&body)
132
+ @body = body
133
+ end
134
+
135
+ def call(*vals)
136
+ @body.call(*vals)
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,13 @@
1
+ # Copyright 2026 OpenC3, Inc.
2
+ # All Rights Reserved.
3
+ #
4
+ # This program is distributed in the hope that it will be useful,
5
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
6
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
7
+ # See LICENSE.md for more details.
8
+
9
+ # frozen_string_literal: true
10
+
11
+ module ERBLite
12
+ VERSION = "0.5.0"
13
+ end
data/lib/erblite.rb ADDED
@@ -0,0 +1,134 @@
1
+ # Copyright 2026 OpenC3, Inc.
2
+ # All Rights Reserved.
3
+ #
4
+ # This program is distributed in the hope that it will be useful,
5
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
6
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
7
+ # See LICENSE.md for more details.
8
+
9
+ # frozen_string_literal: true
10
+
11
+ require_relative "erblite/version"
12
+ require_relative "erblite/error"
13
+ require_relative "erblite/nodes"
14
+ require_relative "erblite/lexer"
15
+ require_relative "erblite/parser"
16
+ require_relative "erblite/values"
17
+ require_relative "erblite/builtins"
18
+ require_relative "erblite/format"
19
+ require_relative "erblite/json"
20
+ require_relative "erblite/sandbox"
21
+ require_relative "erblite/interpreter"
22
+
23
+ # ERBLite: a sandboxed template engine with ERB syntax and a small subset of
24
+ # Ruby. Templates are lexed, parsed into an AST and run by a tree-walking
25
+ # interpreter; no template text is ever evaluated as Ruby code.
26
+ module ERBLite
27
+ NAME_RE = /\A[a-z_][A-Za-z0-9_]*\z/
28
+
29
+ # A compiled template. The source is tokenized once; the token stream is
30
+ # parsed per set of host variable names (host variables are locals of the
31
+ # template, which affects parsing as in Ruby).
32
+ class Template
33
+ attr_reader :filename
34
+
35
+ def initialize(source, filename: "(erblite)")
36
+ @filename = filename
37
+ @source = ERBLite.normalize_source(source, filename)
38
+ @tokens = Lexer.tokenize(@source, filename)
39
+ @asts = {}
40
+ rescue SystemStackError
41
+ raise Error.new("LimitError", "nesting is too deep (stack exhausted)", filename, nil)
42
+ end
43
+
44
+ # Variables may be passed as a Hash or as extra keyword arguments
45
+ # (render(name: "x")); base_dir and max_steps are always options.
46
+ def render(vars = {}, base_dir: nil, max_steps: nil, **more)
47
+ vars = vars.merge(more) unless more.empty?
48
+ locals = ERBLite.convert_vars(vars)
49
+ names = locals.keys
50
+ ast = @asts[names.sort] ||= Parser.parse(@tokens, @filename, names)
51
+ state = State.new(base_dir, max_steps)
52
+ Interpreter.new(state, @filename).run(ast, locals)
53
+ rescue SystemStackError
54
+ raise Error.new("LimitError", "nesting is too deep (stack exhausted)", @filename, nil)
55
+ rescue NoMemoryError
56
+ raise Error.new("LimitError", "out of memory", @filename, nil)
57
+ end
58
+ end
59
+
60
+ module_function
61
+
62
+ def render(source, vars = {}, base_dir: nil, filename: "(erblite)", max_steps: nil, **more)
63
+ Template.new(source, filename: filename).render(vars, base_dir: base_dir, max_steps: max_steps, **more)
64
+ end
65
+
66
+ def render_file(path, vars = {}, base_dir: nil, filename: nil, max_steps: nil, **more)
67
+ vars = vars.merge(more) unless more.empty?
68
+ source = begin
69
+ File.binread(path)
70
+ rescue SystemCallError => e
71
+ raise Error.new("IOError", e.message, path.to_s, nil)
72
+ end
73
+ base_dir ||= File.dirname(File.expand_path(path))
74
+ Template.new(source, filename: filename || path.to_s).render(vars, base_dir: base_dir, max_steps: max_steps)
75
+ end
76
+
77
+ def normalize_source(source, filename)
78
+ raise Error.new("TypeError", "template source must be a String", filename, nil) unless source.is_a?(String)
79
+
80
+ s = source.dup.force_encoding(Encoding::UTF_8)
81
+ raise Error.new("SyntaxError", "template is not valid UTF-8", filename, 1) unless s.valid_encoding?
82
+
83
+ s
84
+ end
85
+
86
+ # Host variables: Symbol or String keys naming valid identifiers. Values
87
+ # are copied into fresh template values (Arrays/Hashes are copied, sharing
88
+ # is preserved), so templates never see host objects.
89
+ def convert_vars(vars)
90
+ raise Error.new("TypeError", "variables must be a Hash") unless vars.is_a?(Hash)
91
+
92
+ memo = {}.compare_by_identity
93
+ out = {}
94
+ vars.each do |k, v|
95
+ unless k.is_a?(Symbol) || k.is_a?(String)
96
+ raise Error.new("TypeError", "variable names must be Symbols or Strings, got #{k.inspect}")
97
+ end
98
+
99
+ name = k.to_s
100
+ next unless name.match?(NAME_RE) && !CodeLexer::KEYWORD_SET[name]
101
+
102
+ out[name] = convert_value(v, memo)
103
+ end
104
+ out
105
+ end
106
+
107
+ def convert_value(v, memo)
108
+ case v
109
+ when nil, true, false, Float, Symbol then v
110
+ when Integer then Builtins.ck(v)
111
+ when String
112
+ s = v.dup
113
+ s.force_encoding(Encoding::UTF_8) unless s.encoding == Encoding::UTF_8
114
+ s
115
+ when Array
116
+ return memo[v] if memo.key?(v)
117
+
118
+ arr = memo[v] = []
119
+ v.each { |x| arr << convert_value(x, memo) }
120
+ arr
121
+ when Hash
122
+ return memo[v] if memo.key?(v)
123
+
124
+ h = memo[v] = {}
125
+ h.default = convert_value(v.default, memo) unless v.default_proc
126
+ v.each { |k, x| h[convert_value(k, memo)] = convert_value(x, memo) }
127
+ h
128
+ when Range
129
+ Range.new(convert_value(v.begin, memo), convert_value(v.end, memo), v.exclude_end?)
130
+ else
131
+ raise Error.new("TypeError", "unsupported variable value of class #{v.class}")
132
+ end
133
+ end
134
+ end
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: erblite
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ platform: ruby
6
+ authors:
7
+ - OpenC3
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: ERBLite renders ERB templates with a tree-walking interpreter for a small,
13
+ sandboxed subset of Ruby. It never evals template code.
14
+ executables:
15
+ - erblite
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - LICENSE.md
20
+ - README.md
21
+ - bin/erblite
22
+ - lib/erblite.rb
23
+ - lib/erblite/builtins.rb
24
+ - lib/erblite/error.rb
25
+ - lib/erblite/format.rb
26
+ - lib/erblite/interpreter.rb
27
+ - lib/erblite/json.rb
28
+ - lib/erblite/lexer.rb
29
+ - lib/erblite/nodes.rb
30
+ - lib/erblite/parser.rb
31
+ - lib/erblite/sandbox.rb
32
+ - lib/erblite/values.rb
33
+ - lib/erblite/version.rb
34
+ homepage: https://github.com/OpenC3/erblite
35
+ licenses:
36
+ - MIT
37
+ metadata:
38
+ source_code_uri: https://github.com/OpenC3/erblite
39
+ bug_tracker_uri: https://github.com/OpenC3/erblite/issues
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.2'
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.6.9
55
+ specification_version: 4
56
+ summary: Sandboxed ERB-syntax template engine running a safe subset of Ruby
57
+ test_files: []