sxn 0.2.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/.gem_rbs_collection/addressable/2.8/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/addressable/2.8/addressable.rbs +62 -0
- data/.gem_rbs_collection/async/2.12/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/async/2.12/async.rbs +119 -0
- data/.gem_rbs_collection/async/2.12/kernel.rbs +5 -0
- data/.gem_rbs_collection/async/2.12/manifest.yaml +7 -0
- data/.gem_rbs_collection/bcrypt/3.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/bcrypt/3.1/bcrypt.rbs +47 -0
- data/.gem_rbs_collection/bcrypt/3.1/manifest.yaml +2 -0
- data/.gem_rbs_collection/bigdecimal/3.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal-math.rbs +119 -0
- data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal.rbs +1630 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +26 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +65 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
- data/.gem_rbs_collection/diff-lcs/1.5/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/diff-lcs/1.5/diff-lcs.rbs +11 -0
- data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
- data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
- data/.gem_rbs_collection/mini_mime/0.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/mini_mime/0.1/mini_mime.rbs +14 -0
- data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
- data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
- data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
- data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +822 -0
- data/.gem_rbs_collection/sqlite3/2.0/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/sqlite3/2.0/database.rbs +20 -0
- data/.gem_rbs_collection/sqlite3/2.0/pragmas.rbs +5 -0
- data/.rspec +4 -0
- data/.rubocop.yml +121 -0
- data/.simplecov +51 -0
- data/CHANGELOG.md +49 -0
- data/Gemfile +24 -0
- data/Gemfile.lock +329 -0
- data/LICENSE.txt +21 -0
- data/README.md +225 -0
- data/Rakefile +54 -0
- data/Steepfile +50 -0
- data/bin/sxn +6 -0
- data/lib/sxn/CLI.rb +275 -0
- data/lib/sxn/commands/init.rb +137 -0
- data/lib/sxn/commands/projects.rb +350 -0
- data/lib/sxn/commands/rules.rb +435 -0
- data/lib/sxn/commands/sessions.rb +300 -0
- data/lib/sxn/commands/worktrees.rb +416 -0
- data/lib/sxn/commands.rb +13 -0
- data/lib/sxn/config/config_cache.rb +295 -0
- data/lib/sxn/config/config_discovery.rb +242 -0
- data/lib/sxn/config/config_validator.rb +562 -0
- data/lib/sxn/config.rb +259 -0
- data/lib/sxn/core/config_manager.rb +290 -0
- data/lib/sxn/core/project_manager.rb +307 -0
- data/lib/sxn/core/rules_manager.rb +306 -0
- data/lib/sxn/core/session_manager.rb +336 -0
- data/lib/sxn/core/worktree_manager.rb +281 -0
- data/lib/sxn/core.rb +13 -0
- data/lib/sxn/database/errors.rb +29 -0
- data/lib/sxn/database/session_database.rb +691 -0
- data/lib/sxn/database.rb +24 -0
- data/lib/sxn/errors.rb +76 -0
- data/lib/sxn/rules/base_rule.rb +367 -0
- data/lib/sxn/rules/copy_files_rule.rb +346 -0
- data/lib/sxn/rules/errors.rb +28 -0
- data/lib/sxn/rules/project_detector.rb +871 -0
- data/lib/sxn/rules/rules_engine.rb +485 -0
- data/lib/sxn/rules/setup_commands_rule.rb +307 -0
- data/lib/sxn/rules/template_rule.rb +262 -0
- data/lib/sxn/rules.rb +148 -0
- data/lib/sxn/runtime_validations.rb +96 -0
- data/lib/sxn/security/secure_command_executor.rb +364 -0
- data/lib/sxn/security/secure_file_copier.rb +478 -0
- data/lib/sxn/security/secure_path_validator.rb +258 -0
- data/lib/sxn/security.rb +15 -0
- data/lib/sxn/templates/common/gitignore.liquid +99 -0
- data/lib/sxn/templates/common/session-info.md.liquid +58 -0
- data/lib/sxn/templates/errors.rb +36 -0
- data/lib/sxn/templates/javascript/README.md.liquid +59 -0
- data/lib/sxn/templates/javascript/session-info.md.liquid +206 -0
- data/lib/sxn/templates/rails/CLAUDE.md.liquid +78 -0
- data/lib/sxn/templates/rails/database.yml.liquid +31 -0
- data/lib/sxn/templates/rails/session-info.md.liquid +144 -0
- data/lib/sxn/templates/template_engine.rb +346 -0
- data/lib/sxn/templates/template_processor.rb +279 -0
- data/lib/sxn/templates/template_security.rb +410 -0
- data/lib/sxn/templates/template_variables.rb +713 -0
- data/lib/sxn/templates.rb +28 -0
- data/lib/sxn/ui/output.rb +103 -0
- data/lib/sxn/ui/progress_bar.rb +91 -0
- data/lib/sxn/ui/prompt.rb +116 -0
- data/lib/sxn/ui/table.rb +183 -0
- data/lib/sxn/ui.rb +12 -0
- data/lib/sxn/version.rb +5 -0
- data/lib/sxn.rb +63 -0
- data/rbs_collection.lock.yaml +180 -0
- data/rbs_collection.yaml +39 -0
- data/scripts/test.sh +31 -0
- data/sig/external/liquid.rbs +116 -0
- data/sig/external/thor.rbs +99 -0
- data/sig/external/tty.rbs +71 -0
- data/sig/sxn/cli.rbs +46 -0
- data/sig/sxn/commands/init.rbs +38 -0
- data/sig/sxn/commands/projects.rbs +72 -0
- data/sig/sxn/commands/rules.rbs +95 -0
- data/sig/sxn/commands/sessions.rbs +62 -0
- data/sig/sxn/commands/worktrees.rbs +82 -0
- data/sig/sxn/commands.rbs +6 -0
- data/sig/sxn/config/config_cache.rbs +67 -0
- data/sig/sxn/config/config_discovery.rbs +64 -0
- data/sig/sxn/config/config_validator.rbs +64 -0
- data/sig/sxn/config.rbs +74 -0
- data/sig/sxn/core/config_manager.rbs +67 -0
- data/sig/sxn/core/project_manager.rbs +52 -0
- data/sig/sxn/core/rules_manager.rbs +54 -0
- data/sig/sxn/core/session_manager.rbs +59 -0
- data/sig/sxn/core/worktree_manager.rbs +50 -0
- data/sig/sxn/core.rbs +87 -0
- data/sig/sxn/database/errors.rbs +37 -0
- data/sig/sxn/database/session_database.rbs +151 -0
- data/sig/sxn/database.rbs +83 -0
- data/sig/sxn/errors.rbs +89 -0
- data/sig/sxn/rules/base_rule.rbs +137 -0
- data/sig/sxn/rules/copy_files_rule.rbs +65 -0
- data/sig/sxn/rules/errors.rbs +33 -0
- data/sig/sxn/rules/project_detector.rbs +115 -0
- data/sig/sxn/rules/rules_engine.rbs +118 -0
- data/sig/sxn/rules/setup_commands_rule.rbs +60 -0
- data/sig/sxn/rules/template_rule.rbs +44 -0
- data/sig/sxn/rules.rbs +287 -0
- data/sig/sxn/runtime_validations.rbs +16 -0
- data/sig/sxn/security/secure_command_executor.rbs +63 -0
- data/sig/sxn/security/secure_file_copier.rbs +79 -0
- data/sig/sxn/security/secure_path_validator.rbs +30 -0
- data/sig/sxn/security.rbs +128 -0
- data/sig/sxn/templates/errors.rbs +43 -0
- data/sig/sxn/templates/template_engine.rbs +50 -0
- data/sig/sxn/templates/template_processor.rbs +44 -0
- data/sig/sxn/templates/template_security.rbs +62 -0
- data/sig/sxn/templates/template_variables.rbs +103 -0
- data/sig/sxn/templates.rbs +104 -0
- data/sig/sxn/ui/output.rbs +50 -0
- data/sig/sxn/ui/progress_bar.rbs +39 -0
- data/sig/sxn/ui/prompt.rbs +38 -0
- data/sig/sxn/ui/table.rbs +43 -0
- data/sig/sxn/ui.rbs +63 -0
- data/sig/sxn/version.rbs +5 -0
- data/sig/sxn.rbs +29 -0
- metadata +635 -0
@@ -0,0 +1,346 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "template_processor"
|
4
|
+
require_relative "template_variables"
|
5
|
+
require_relative "template_security"
|
6
|
+
require_relative "errors"
|
7
|
+
require_relative "../runtime_validations"
|
8
|
+
|
9
|
+
# Add support for hash deep merging if not available
|
10
|
+
unless Hash.method_defined?(:deep_merge)
|
11
|
+
class Hash
|
12
|
+
def deep_merge(other_hash)
|
13
|
+
merge(other_hash) do |_key, oldval, newval|
|
14
|
+
if oldval.is_a?(Hash) && newval.is_a?(Hash)
|
15
|
+
oldval.deep_merge(newval)
|
16
|
+
else
|
17
|
+
newval
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
module Sxn
|
25
|
+
module Templates
|
26
|
+
# TemplateEngine is the main interface for template processing in sxn.
|
27
|
+
# It combines template processing, variable collection, and security validation
|
28
|
+
# to provide a safe and convenient API for generating files from templates.
|
29
|
+
#
|
30
|
+
# Features:
|
31
|
+
# - Built-in template discovery
|
32
|
+
# - Automatic variable collection
|
33
|
+
# - Security validation
|
34
|
+
# - Template caching
|
35
|
+
# - Multiple template formats support
|
36
|
+
#
|
37
|
+
# Example:
|
38
|
+
# engine = TemplateEngine.new(session: session, project: project)
|
39
|
+
# engine.process_template("rails/CLAUDE.md", "/path/to/output.md")
|
40
|
+
class TemplateEngine
|
41
|
+
# Built-in template directory
|
42
|
+
TEMPLATES_DIR = File.expand_path("../templates", __dir__)
|
43
|
+
|
44
|
+
def initialize(session: nil, project: nil, config: nil)
|
45
|
+
@session = session
|
46
|
+
@project = project
|
47
|
+
@config = config
|
48
|
+
|
49
|
+
@processor = TemplateProcessor.new
|
50
|
+
@variables_collector = TemplateVariables.new(session, project, config)
|
51
|
+
@security = TemplateSecurity.new
|
52
|
+
@template_cache = {}
|
53
|
+
end
|
54
|
+
|
55
|
+
# Process a template and write it to the specified destination
|
56
|
+
#
|
57
|
+
# @param template_name [String] Name/path of the template (e.g., "rails/CLAUDE.md")
|
58
|
+
# @param destination_path [String] Where to write the processed template
|
59
|
+
# @param custom_variables [Hash] Additional variables to merge
|
60
|
+
# @param options [Hash] Processing options
|
61
|
+
# @option options [Boolean] :force (false) Overwrite existing files
|
62
|
+
# @option options [Boolean] :validate (true) Validate template security
|
63
|
+
# @option options [String] :template_dir Custom template directory
|
64
|
+
# @return [String] Path to the created file
|
65
|
+
def process_template(template_name, destination_path, custom_variables = {}, options = {})
|
66
|
+
options = { force: false, validate: true, template_dir: nil }.merge(options)
|
67
|
+
|
68
|
+
# Find the template file
|
69
|
+
template_path = find_template(template_name, options[:template_dir])
|
70
|
+
|
71
|
+
# Check if destination exists and handle accordingly
|
72
|
+
destination = Pathname.new(destination_path)
|
73
|
+
if destination.exist? && !options[:force]
|
74
|
+
raise Errors::TemplateError,
|
75
|
+
"Destination file already exists: #{destination_path}. Use force: true to overwrite."
|
76
|
+
end
|
77
|
+
|
78
|
+
# steep:ignore:start - Template processing uses dynamic variable resolution
|
79
|
+
# Liquid template processing and variable collection use dynamic features
|
80
|
+
# that cannot be statically typed. Runtime validation provides safety.
|
81
|
+
|
82
|
+
# Collect variables
|
83
|
+
variables = collect_variables(custom_variables)
|
84
|
+
|
85
|
+
# Runtime validation of template variables
|
86
|
+
variables = RuntimeValidations.validate_template_variables(variables)
|
87
|
+
|
88
|
+
# Validate template security if requested
|
89
|
+
if options[:validate]
|
90
|
+
template_content = File.read(template_path)
|
91
|
+
@security.validate_template(template_content, variables)
|
92
|
+
end
|
93
|
+
|
94
|
+
# Process the template with runtime validation
|
95
|
+
result = @processor.process_file(template_path, variables, options)
|
96
|
+
|
97
|
+
# Create destination directory if it doesn't exist
|
98
|
+
destination.dirname.mkpath
|
99
|
+
|
100
|
+
# Write the result
|
101
|
+
destination.write(result)
|
102
|
+
|
103
|
+
destination_path
|
104
|
+
rescue Errors::TemplateSecurityError
|
105
|
+
# Re-raise security errors without wrapping
|
106
|
+
raise
|
107
|
+
rescue StandardError => e
|
108
|
+
raise Errors::TemplateProcessingError,
|
109
|
+
"Failed to process template '#{template_name}': #{e.message}"
|
110
|
+
end
|
111
|
+
|
112
|
+
# List available built-in templates
|
113
|
+
#
|
114
|
+
# @param category [String] Optional category filter (rails, javascript, common)
|
115
|
+
# @return [Array<String>] List of available template names
|
116
|
+
def list_templates(category = nil)
|
117
|
+
templates_dir = category ? File.join(TEMPLATES_DIR, category) : TEMPLATES_DIR
|
118
|
+
return [] unless Dir.exist?(templates_dir)
|
119
|
+
|
120
|
+
Dir.glob("**/*.liquid", base: templates_dir).map do |path|
|
121
|
+
category ? File.join(category, path) : path
|
122
|
+
end.sort
|
123
|
+
end
|
124
|
+
|
125
|
+
# Get template categories
|
126
|
+
#
|
127
|
+
# @return [Array<String>] List of template categories
|
128
|
+
def template_categories
|
129
|
+
return [] unless Dir.exist?(TEMPLATES_DIR)
|
130
|
+
|
131
|
+
Dir.entries(TEMPLATES_DIR)
|
132
|
+
.select { |entry| File.directory?(File.join(TEMPLATES_DIR, entry)) }
|
133
|
+
.reject { |entry| entry.start_with?(".") }
|
134
|
+
.sort
|
135
|
+
end
|
136
|
+
|
137
|
+
# Check if a template exists
|
138
|
+
#
|
139
|
+
# @param template_name [String] Name of the template to check
|
140
|
+
# @param template_dir [String] Optional custom template directory
|
141
|
+
# @return [Boolean] true if template exists
|
142
|
+
def template_exists?(template_name, template_dir = nil)
|
143
|
+
find_template(template_name, template_dir)
|
144
|
+
true
|
145
|
+
rescue Errors::TemplateNotFoundError
|
146
|
+
false
|
147
|
+
end
|
148
|
+
|
149
|
+
# Get template information
|
150
|
+
#
|
151
|
+
# @param template_name [String] Name of the template
|
152
|
+
# @param template_dir [String] Optional custom template directory
|
153
|
+
# @return [Hash] Template metadata
|
154
|
+
def template_info(template_name, template_dir = nil)
|
155
|
+
template_path = find_template(template_name, template_dir)
|
156
|
+
template_content = File.read(template_path)
|
157
|
+
|
158
|
+
{
|
159
|
+
name: template_name,
|
160
|
+
path: template_path,
|
161
|
+
size: template_content.bytesize,
|
162
|
+
variables: @processor.extract_variables(template_content),
|
163
|
+
syntax_valid: validate_template_syntax(template_content)
|
164
|
+
}
|
165
|
+
rescue StandardError => e
|
166
|
+
{
|
167
|
+
name: template_name,
|
168
|
+
error: e.message,
|
169
|
+
syntax_valid: false
|
170
|
+
}
|
171
|
+
end
|
172
|
+
|
173
|
+
# Validate template syntax without processing
|
174
|
+
#
|
175
|
+
# @param template_name [String] Name of the template
|
176
|
+
# @param template_dir [String] Optional custom template directory
|
177
|
+
# @return [Boolean] true if template syntax is valid
|
178
|
+
def validate_template_syntax(template_name, template_dir = nil)
|
179
|
+
# Better detection: if it contains Liquid syntax and no path separators, treat as content
|
180
|
+
if template_name.is_a?(String) &&
|
181
|
+
(template_name.include?("{{") || template_name.include?("{%")) &&
|
182
|
+
!template_name.include?("/") && !template_name.end_with?(".liquid")
|
183
|
+
# It's template content with Liquid syntax
|
184
|
+
template_content = template_name
|
185
|
+
elsif template_name.is_a?(String) && !template_name.include?("\n") &&
|
186
|
+
(template_name.include?("/") || template_name.match?(/\.\w+$/) || !template_name.include?("{{"))
|
187
|
+
# It's a template name/path
|
188
|
+
template_path = find_template(template_name, template_dir)
|
189
|
+
template_content = File.read(template_path)
|
190
|
+
else
|
191
|
+
# Default: treat as content for backward compatibility
|
192
|
+
template_content = template_name
|
193
|
+
end
|
194
|
+
|
195
|
+
@processor.validate_syntax(template_content)
|
196
|
+
rescue Errors::TemplateSyntaxError, Errors::TemplateNotFoundError
|
197
|
+
false
|
198
|
+
end
|
199
|
+
|
200
|
+
# Get available variables for templates
|
201
|
+
#
|
202
|
+
# @param custom_variables [Hash] Additional variables to include
|
203
|
+
# @return [Hash] All available variables
|
204
|
+
def available_variables(custom_variables = {})
|
205
|
+
collect_variables(custom_variables)
|
206
|
+
end
|
207
|
+
|
208
|
+
# Refresh variable cache (useful for long-running processes)
|
209
|
+
def refresh_variables!
|
210
|
+
@variables_collector.refresh!
|
211
|
+
end
|
212
|
+
|
213
|
+
# Clear template cache
|
214
|
+
def clear_cache!
|
215
|
+
@template_cache.clear
|
216
|
+
@security.clear_cache!
|
217
|
+
end
|
218
|
+
|
219
|
+
# Process a template string directly (not from file)
|
220
|
+
#
|
221
|
+
# @param template_content [String] The template content
|
222
|
+
# @param custom_variables [Hash] Variables to use
|
223
|
+
# @param options [Hash] Processing options
|
224
|
+
# @return [String] Processed template
|
225
|
+
def process_string(template_content, custom_variables = {}, options = {})
|
226
|
+
options = { validate: true }.merge(options)
|
227
|
+
|
228
|
+
variables = collect_variables(custom_variables)
|
229
|
+
|
230
|
+
@security.validate_template(template_content, variables) if options[:validate]
|
231
|
+
|
232
|
+
@processor.process(template_content, variables, options)
|
233
|
+
end
|
234
|
+
|
235
|
+
# Render a template with variables
|
236
|
+
#
|
237
|
+
# @param template_name [String] Name/path of the template
|
238
|
+
# @param variables [Hash] Variables to use for rendering
|
239
|
+
# @param options [Hash] Processing options
|
240
|
+
# @return [String] Rendered template content
|
241
|
+
def render_template(template_name, variables = {}, options = {})
|
242
|
+
# Find the template file
|
243
|
+
template_path = find_template(template_name, options[:template_dir])
|
244
|
+
|
245
|
+
# Read template content
|
246
|
+
template_content = File.read(template_path)
|
247
|
+
|
248
|
+
# Merge with available variables
|
249
|
+
all_variables = collect_variables(variables)
|
250
|
+
|
251
|
+
# Validate template security if requested
|
252
|
+
@security.validate_template(template_content, all_variables) if options.fetch(:validate, true)
|
253
|
+
|
254
|
+
# Process and return the result
|
255
|
+
@processor.process(template_content, all_variables, options)
|
256
|
+
rescue Errors::TemplateSecurityError
|
257
|
+
# Re-raise security errors without wrapping
|
258
|
+
raise
|
259
|
+
rescue StandardError => e
|
260
|
+
raise Errors::TemplateProcessingError,
|
261
|
+
"Failed to render template '#{template_name}': #{e.message}"
|
262
|
+
end
|
263
|
+
|
264
|
+
# Apply a set of templates to a directory
|
265
|
+
#
|
266
|
+
# @param template_set [String] Name of template set (rails, javascript, common)
|
267
|
+
# @param destination_dir [String] Directory to apply templates to
|
268
|
+
# @param custom_variables [Hash] Additional variables
|
269
|
+
# @param options [Hash] Processing options
|
270
|
+
# @return [Array<String>] List of created files
|
271
|
+
def apply_template_set(template_set, destination_dir, custom_variables = {}, options = {})
|
272
|
+
templates = list_templates(template_set)
|
273
|
+
created_files = []
|
274
|
+
|
275
|
+
templates.each do |template_name|
|
276
|
+
# Determine output filename (remove .liquid extension)
|
277
|
+
output_name = template_name.sub(/\.liquid$/, "")
|
278
|
+
output_path = File.join(destination_dir, File.basename(output_name))
|
279
|
+
|
280
|
+
begin
|
281
|
+
process_template(template_name, output_path, custom_variables, options)
|
282
|
+
created_files << output_path
|
283
|
+
rescue StandardError => e
|
284
|
+
# Log error but continue with other templates
|
285
|
+
warn "Failed to process template #{template_name}: #{e.message}"
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
created_files
|
290
|
+
end
|
291
|
+
|
292
|
+
private
|
293
|
+
|
294
|
+
# Find a template file by name
|
295
|
+
def find_template(template_name, custom_template_dir = nil)
|
296
|
+
# Remove .liquid extension if present for search
|
297
|
+
search_name = template_name.sub(/\.liquid$/, "")
|
298
|
+
|
299
|
+
# Search locations in order of preference
|
300
|
+
search_paths = []
|
301
|
+
|
302
|
+
# 1. Custom template directory if provided
|
303
|
+
if custom_template_dir
|
304
|
+
search_paths << File.join(custom_template_dir, "#{search_name}.liquid")
|
305
|
+
search_paths << File.join(custom_template_dir, search_name)
|
306
|
+
end
|
307
|
+
|
308
|
+
# 2. Built-in templates
|
309
|
+
search_paths << File.join(TEMPLATES_DIR, "#{search_name}.liquid")
|
310
|
+
search_paths << File.join(TEMPLATES_DIR, search_name)
|
311
|
+
|
312
|
+
# 3. Try with explicit .liquid extension
|
313
|
+
search_paths << File.join(TEMPLATES_DIR, template_name) if template_name.end_with?(".liquid")
|
314
|
+
|
315
|
+
# Find first existing template
|
316
|
+
found_path = search_paths.find { |path| File.exist?(path) }
|
317
|
+
|
318
|
+
unless found_path
|
319
|
+
available = list_templates.join(", ")
|
320
|
+
raise Errors::TemplateNotFoundError,
|
321
|
+
"Template '#{template_name}' not found. Available templates: #{available}"
|
322
|
+
end
|
323
|
+
|
324
|
+
found_path
|
325
|
+
end
|
326
|
+
|
327
|
+
# Collect all variables for template processing
|
328
|
+
def collect_variables(custom_variables = {})
|
329
|
+
# Get base variables from collector
|
330
|
+
base_variables = @variables_collector.collect
|
331
|
+
|
332
|
+
# Add sxn-specific variables
|
333
|
+
sxn_variables = {
|
334
|
+
sxn: {
|
335
|
+
version: Sxn::VERSION,
|
336
|
+
template_engine: "liquid",
|
337
|
+
generated_at: Time.now.iso8601
|
338
|
+
}
|
339
|
+
}
|
340
|
+
|
341
|
+
# Merge all variables (custom takes precedence)
|
342
|
+
base_variables.deep_merge(sxn_variables).deep_merge(custom_variables)
|
343
|
+
end
|
344
|
+
end
|
345
|
+
end
|
346
|
+
end
|
@@ -0,0 +1,279 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "liquid"
|
4
|
+
require "pathname"
|
5
|
+
|
6
|
+
module Sxn
|
7
|
+
module Templates
|
8
|
+
# TemplateProcessor provides secure, sandboxed template processing using Liquid.
|
9
|
+
# It ensures that templates cannot execute arbitrary code or access the filesystem.
|
10
|
+
#
|
11
|
+
# Features:
|
12
|
+
# - Whitelisted variables only
|
13
|
+
# - No arbitrary code execution
|
14
|
+
# - Support for nested variable access (session.name, git.branch)
|
15
|
+
# - Built-in filters (upcase, downcase, join, etc.)
|
16
|
+
# - Template validation before processing
|
17
|
+
#
|
18
|
+
# Example:
|
19
|
+
# processor = TemplateProcessor.new
|
20
|
+
# variables = { session: { name: "test" }, git: { branch: "main" } }
|
21
|
+
# result = processor.process("Hello {{session.name}} on {{git.branch}}", variables)
|
22
|
+
# # => "Hello test on main"
|
23
|
+
class TemplateProcessor
|
24
|
+
# Maximum template size in bytes to prevent memory exhaustion
|
25
|
+
MAX_TEMPLATE_SIZE = 1_048_576 # 1MB
|
26
|
+
|
27
|
+
# Maximum rendering time in seconds to prevent infinite loops
|
28
|
+
MAX_RENDER_TIME = 10
|
29
|
+
|
30
|
+
# Allowed Liquid filters for security
|
31
|
+
ALLOWED_FILTERS = %w[
|
32
|
+
upcase downcase capitalize
|
33
|
+
strip lstrip rstrip
|
34
|
+
size length
|
35
|
+
first last
|
36
|
+
join split
|
37
|
+
sort sort_natural reverse
|
38
|
+
uniq compact
|
39
|
+
date
|
40
|
+
default
|
41
|
+
escape escape_once
|
42
|
+
truncate truncatewords
|
43
|
+
replace replace_first
|
44
|
+
remove remove_first
|
45
|
+
plus minus times divided_by modulo
|
46
|
+
abs ceil floor round
|
47
|
+
at_least at_most
|
48
|
+
].freeze
|
49
|
+
|
50
|
+
def initialize
|
51
|
+
create_secure_liquid_environment
|
52
|
+
end
|
53
|
+
|
54
|
+
# Process a template string with the given variables
|
55
|
+
#
|
56
|
+
# @param template_content [String] The template content to process
|
57
|
+
# @param variables [Hash] Variables to make available in the template
|
58
|
+
# @param options [Hash] Processing options
|
59
|
+
# @option options [Boolean] :strict (true) Whether to raise on undefined variables
|
60
|
+
# @option options [Boolean] :validate (true) Whether to validate template syntax first
|
61
|
+
# @return [String] The processed template
|
62
|
+
# @raise [TemplateTooLargeError] if template exceeds size limit
|
63
|
+
# @raise [TemplateTimeoutError] if processing takes too long
|
64
|
+
# @raise [TemplateSecurityError] if template contains disallowed content
|
65
|
+
# @raise [TemplateSyntaxError] if template has invalid syntax
|
66
|
+
def process(template_content, variables = {}, options = {})
|
67
|
+
options = { strict: true, validate: true }.merge(options)
|
68
|
+
|
69
|
+
validate_template_size!(template_content)
|
70
|
+
|
71
|
+
# Sanitize and whitelist variables
|
72
|
+
sanitized_variables = sanitize_variables(variables)
|
73
|
+
|
74
|
+
# Parse template with syntax validation
|
75
|
+
template = parse_template(template_content, validate: options[:validate])
|
76
|
+
|
77
|
+
# Render with timeout protection
|
78
|
+
render_with_timeout(template, sanitized_variables, options)
|
79
|
+
rescue Liquid::SyntaxError => e
|
80
|
+
raise Errors::TemplateSyntaxError, "Template syntax error: #{e.message}"
|
81
|
+
rescue Errors::TemplateTooLargeError, Errors::TemplateTimeoutError, Errors::TemplateRenderError => e
|
82
|
+
# Re-raise specific template errors as-is
|
83
|
+
raise e
|
84
|
+
rescue StandardError => e
|
85
|
+
raise Errors::TemplateProcessingError, "Template processing failed: #{e.message}"
|
86
|
+
end
|
87
|
+
|
88
|
+
# Process a template file with the given variables
|
89
|
+
#
|
90
|
+
# @param template_path [String, Pathname] Path to the template file
|
91
|
+
# @param variables [Hash] Variables to make available in the template
|
92
|
+
# @param options [Hash] Processing options (see #process)
|
93
|
+
# @return [String] The processed template
|
94
|
+
# @raise [TemplateNotFoundError] if template file doesn't exist
|
95
|
+
def process_file(template_path, variables = {}, options = {})
|
96
|
+
template_path = Pathname.new(template_path)
|
97
|
+
|
98
|
+
raise Errors::TemplateNotFoundError, "Template file not found: #{template_path}" unless template_path.exist?
|
99
|
+
|
100
|
+
template_content = template_path.read
|
101
|
+
process(template_content, variables, options)
|
102
|
+
end
|
103
|
+
|
104
|
+
# Validate template syntax without processing
|
105
|
+
#
|
106
|
+
# @param template_content [String] The template content to validate
|
107
|
+
# @return [Boolean] true if template is valid
|
108
|
+
# @raise [TemplateSyntaxError] if template has invalid syntax
|
109
|
+
def validate_syntax(template_content)
|
110
|
+
validate_template_size!(template_content)
|
111
|
+
parse_template(template_content, validate: true)
|
112
|
+
true
|
113
|
+
rescue Liquid::SyntaxError => e
|
114
|
+
raise Errors::TemplateSyntaxError, "Template syntax error: #{e.message}"
|
115
|
+
end
|
116
|
+
|
117
|
+
# Extract variables referenced in a template
|
118
|
+
#
|
119
|
+
# @param template_content [String] The template content to analyze
|
120
|
+
# @return [Array<String>] List of variable names referenced in the template
|
121
|
+
def extract_variables(template_content)
|
122
|
+
variables = Set.new
|
123
|
+
loop_variables = Set.new
|
124
|
+
|
125
|
+
# Extract variables from {% if/unless variable %} expressions
|
126
|
+
template_content.scan(/\{%\s*(?:if|unless)\s+(\w+)(?:\.\w+)*.*?%\}/) do |match|
|
127
|
+
variables.add(match[0])
|
128
|
+
end
|
129
|
+
|
130
|
+
# Extract collection variables from {% for item in collection %} expressions
|
131
|
+
template_content.scan(/\{%\s*for\s+(\w+)\s+in\s+(\w+)(?:\.\w+)*.*?%\}/) do |loop_var, collection_var|
|
132
|
+
loop_variables.add(loop_var)
|
133
|
+
variables.add(collection_var)
|
134
|
+
end
|
135
|
+
|
136
|
+
# Extract variables from {{ variable }} expressions, excluding loop variables
|
137
|
+
# But only from outside control blocks
|
138
|
+
content_outside_blocks = template_content.dup
|
139
|
+
|
140
|
+
# Remove content inside control blocks to avoid extracting variables from inside conditionals
|
141
|
+
content_outside_blocks.gsub!(/\{%\s*if\s+.*?\{%\s*endif\s*%\}/m, "")
|
142
|
+
content_outside_blocks.gsub!(/\{%\s*unless\s+.*?\{%\s*endunless\s*%\}/m, "")
|
143
|
+
content_outside_blocks.gsub!(/\{%\s*for\s+.*?\{%\s*endfor\s*%\}/m, "")
|
144
|
+
|
145
|
+
content_outside_blocks.scan(/\{\{\s*(\w+)(?:\.\w+)*.*?\}\}/) do |match|
|
146
|
+
var_name = match[0]
|
147
|
+
variables.add(var_name) unless loop_variables.include?(var_name)
|
148
|
+
end
|
149
|
+
|
150
|
+
variables.to_a.sort
|
151
|
+
end
|
152
|
+
|
153
|
+
private
|
154
|
+
|
155
|
+
# Create a secure Liquid environment with restricted capabilities
|
156
|
+
def create_secure_liquid_environment
|
157
|
+
# Configure security globally for this processor
|
158
|
+
# Note: These settings affect global state, but we restore them in cleanup if needed
|
159
|
+
|
160
|
+
# Disable dangerous tags in global registry
|
161
|
+
Liquid::Template.tags.delete("include")
|
162
|
+
Liquid::Template.tags.delete("include_relative")
|
163
|
+
Liquid::Template.tags.delete("render")
|
164
|
+
|
165
|
+
true
|
166
|
+
end
|
167
|
+
|
168
|
+
# Validate template size to prevent memory exhaustion
|
169
|
+
def validate_template_size!(template_content)
|
170
|
+
size = template_content.bytesize
|
171
|
+
return if size <= MAX_TEMPLATE_SIZE
|
172
|
+
|
173
|
+
raise Errors::TemplateTooLargeError,
|
174
|
+
"Template size #{size} bytes exceeds limit of #{MAX_TEMPLATE_SIZE} bytes"
|
175
|
+
end
|
176
|
+
|
177
|
+
# Parse template and optionally validate syntax
|
178
|
+
def parse_template(template_content, validate: true)
|
179
|
+
if validate
|
180
|
+
# First pass: syntax validation only
|
181
|
+
Liquid::Template.parse(template_content, error_mode: :strict)
|
182
|
+
end
|
183
|
+
|
184
|
+
# Second pass: actual parsing for rendering
|
185
|
+
Liquid::Template.parse(template_content, error_mode: :strict)
|
186
|
+
end
|
187
|
+
|
188
|
+
# Sanitize and whitelist variables to prevent injection
|
189
|
+
def sanitize_variables(variables)
|
190
|
+
sanitized = {}
|
191
|
+
|
192
|
+
variables.each do |key, value|
|
193
|
+
sanitized_key = sanitize_key(key)
|
194
|
+
sanitized_value = sanitize_value(value)
|
195
|
+
sanitized[sanitized_key] = sanitized_value
|
196
|
+
end
|
197
|
+
|
198
|
+
sanitized
|
199
|
+
end
|
200
|
+
|
201
|
+
# Sanitize variable keys to ensure they're safe
|
202
|
+
def sanitize_key(key)
|
203
|
+
key.to_s.gsub(/[^a-zA-Z0-9_]/, "_")
|
204
|
+
end
|
205
|
+
|
206
|
+
# Recursively sanitize variable values
|
207
|
+
def sanitize_value(value)
|
208
|
+
case value
|
209
|
+
when Hash
|
210
|
+
value.transform_keys { |k| sanitize_key(k) }
|
211
|
+
.transform_values { |v| sanitize_value(v) }
|
212
|
+
when Array
|
213
|
+
value.map { |v| sanitize_value(v) }
|
214
|
+
when String
|
215
|
+
# Escape any potential HTML/JS in strings
|
216
|
+
value.gsub(%r{<script\b[^<]*(?:(?!</script>)<[^<]*)*</script>}mi, "")
|
217
|
+
.gsub(/<[^>]*>/, "")
|
218
|
+
when Symbol
|
219
|
+
value.to_s
|
220
|
+
when Numeric, TrueClass, FalseClass, NilClass
|
221
|
+
value
|
222
|
+
when Time, Date
|
223
|
+
value.iso8601
|
224
|
+
else
|
225
|
+
# Convert unknown types to string representation
|
226
|
+
value.to_s
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
# Render template with timeout protection
|
231
|
+
def render_with_timeout(template, variables, options)
|
232
|
+
start_time = Time.now
|
233
|
+
|
234
|
+
# Set up a thread to handle timeout
|
235
|
+
timeout_thread = Thread.new do
|
236
|
+
sleep(MAX_RENDER_TIME)
|
237
|
+
Thread.main.raise(Errors::TemplateTimeoutError,
|
238
|
+
"Template rendering exceeded #{MAX_RENDER_TIME} seconds")
|
239
|
+
end
|
240
|
+
|
241
|
+
begin
|
242
|
+
# Create rendering context with security settings
|
243
|
+
# For Liquid 5.x, we need to use the Context object for strict control
|
244
|
+
|
245
|
+
# Create context with variables and options
|
246
|
+
context = Liquid::Context.new(
|
247
|
+
variables, # assigns
|
248
|
+
{}, # instance_assigns
|
249
|
+
{
|
250
|
+
strict_variables: options[:strict],
|
251
|
+
strict_filters: false
|
252
|
+
}
|
253
|
+
)
|
254
|
+
|
255
|
+
result = template.render(context)
|
256
|
+
|
257
|
+
# Check for rendering errors
|
258
|
+
if template.errors.any?
|
259
|
+
error_message = template.errors.join(", ")
|
260
|
+
raise Errors::TemplateRenderError, "Template rendering errors: #{error_message}"
|
261
|
+
end
|
262
|
+
|
263
|
+
result
|
264
|
+
ensure
|
265
|
+
timeout_thread.kill
|
266
|
+
|
267
|
+
# Log performance metrics in debug mode
|
268
|
+
if ENV["SXN_DEBUG"]
|
269
|
+
elapsed = Time.now - start_time
|
270
|
+
puts "Template rendered in #{elapsed.round(3)}s"
|
271
|
+
end
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
# Alias for validate_syntax to match expected interface
|
276
|
+
alias validate_template validate_syntax
|
277
|
+
end
|
278
|
+
end
|
279
|
+
end
|