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.
Files changed (156) hide show
  1. checksums.yaml +7 -0
  2. data/.gem_rbs_collection/addressable/2.8/.rbs_meta.yaml +9 -0
  3. data/.gem_rbs_collection/addressable/2.8/addressable.rbs +62 -0
  4. data/.gem_rbs_collection/async/2.12/.rbs_meta.yaml +9 -0
  5. data/.gem_rbs_collection/async/2.12/async.rbs +119 -0
  6. data/.gem_rbs_collection/async/2.12/kernel.rbs +5 -0
  7. data/.gem_rbs_collection/async/2.12/manifest.yaml +7 -0
  8. data/.gem_rbs_collection/bcrypt/3.1/.rbs_meta.yaml +9 -0
  9. data/.gem_rbs_collection/bcrypt/3.1/bcrypt.rbs +47 -0
  10. data/.gem_rbs_collection/bcrypt/3.1/manifest.yaml +2 -0
  11. data/.gem_rbs_collection/bigdecimal/3.1/.rbs_meta.yaml +9 -0
  12. data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal-math.rbs +119 -0
  13. data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal.rbs +1630 -0
  14. data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
  15. data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
  16. data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +26 -0
  17. data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
  18. data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +65 -0
  19. data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
  20. data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
  21. data/.gem_rbs_collection/diff-lcs/1.5/.rbs_meta.yaml +9 -0
  22. data/.gem_rbs_collection/diff-lcs/1.5/diff-lcs.rbs +11 -0
  23. data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
  24. data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
  25. data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
  26. data/.gem_rbs_collection/mini_mime/0.1/.rbs_meta.yaml +9 -0
  27. data/.gem_rbs_collection/mini_mime/0.1/mini_mime.rbs +14 -0
  28. data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
  29. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
  30. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
  31. data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
  32. data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
  33. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
  34. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +822 -0
  35. data/.gem_rbs_collection/sqlite3/2.0/.rbs_meta.yaml +9 -0
  36. data/.gem_rbs_collection/sqlite3/2.0/database.rbs +20 -0
  37. data/.gem_rbs_collection/sqlite3/2.0/pragmas.rbs +5 -0
  38. data/.rspec +4 -0
  39. data/.rubocop.yml +121 -0
  40. data/.simplecov +51 -0
  41. data/CHANGELOG.md +49 -0
  42. data/Gemfile +24 -0
  43. data/Gemfile.lock +329 -0
  44. data/LICENSE.txt +21 -0
  45. data/README.md +225 -0
  46. data/Rakefile +54 -0
  47. data/Steepfile +50 -0
  48. data/bin/sxn +6 -0
  49. data/lib/sxn/CLI.rb +275 -0
  50. data/lib/sxn/commands/init.rb +137 -0
  51. data/lib/sxn/commands/projects.rb +350 -0
  52. data/lib/sxn/commands/rules.rb +435 -0
  53. data/lib/sxn/commands/sessions.rb +300 -0
  54. data/lib/sxn/commands/worktrees.rb +416 -0
  55. data/lib/sxn/commands.rb +13 -0
  56. data/lib/sxn/config/config_cache.rb +295 -0
  57. data/lib/sxn/config/config_discovery.rb +242 -0
  58. data/lib/sxn/config/config_validator.rb +562 -0
  59. data/lib/sxn/config.rb +259 -0
  60. data/lib/sxn/core/config_manager.rb +290 -0
  61. data/lib/sxn/core/project_manager.rb +307 -0
  62. data/lib/sxn/core/rules_manager.rb +306 -0
  63. data/lib/sxn/core/session_manager.rb +336 -0
  64. data/lib/sxn/core/worktree_manager.rb +281 -0
  65. data/lib/sxn/core.rb +13 -0
  66. data/lib/sxn/database/errors.rb +29 -0
  67. data/lib/sxn/database/session_database.rb +691 -0
  68. data/lib/sxn/database.rb +24 -0
  69. data/lib/sxn/errors.rb +76 -0
  70. data/lib/sxn/rules/base_rule.rb +367 -0
  71. data/lib/sxn/rules/copy_files_rule.rb +346 -0
  72. data/lib/sxn/rules/errors.rb +28 -0
  73. data/lib/sxn/rules/project_detector.rb +871 -0
  74. data/lib/sxn/rules/rules_engine.rb +485 -0
  75. data/lib/sxn/rules/setup_commands_rule.rb +307 -0
  76. data/lib/sxn/rules/template_rule.rb +262 -0
  77. data/lib/sxn/rules.rb +148 -0
  78. data/lib/sxn/runtime_validations.rb +96 -0
  79. data/lib/sxn/security/secure_command_executor.rb +364 -0
  80. data/lib/sxn/security/secure_file_copier.rb +478 -0
  81. data/lib/sxn/security/secure_path_validator.rb +258 -0
  82. data/lib/sxn/security.rb +15 -0
  83. data/lib/sxn/templates/common/gitignore.liquid +99 -0
  84. data/lib/sxn/templates/common/session-info.md.liquid +58 -0
  85. data/lib/sxn/templates/errors.rb +36 -0
  86. data/lib/sxn/templates/javascript/README.md.liquid +59 -0
  87. data/lib/sxn/templates/javascript/session-info.md.liquid +206 -0
  88. data/lib/sxn/templates/rails/CLAUDE.md.liquid +78 -0
  89. data/lib/sxn/templates/rails/database.yml.liquid +31 -0
  90. data/lib/sxn/templates/rails/session-info.md.liquid +144 -0
  91. data/lib/sxn/templates/template_engine.rb +346 -0
  92. data/lib/sxn/templates/template_processor.rb +279 -0
  93. data/lib/sxn/templates/template_security.rb +410 -0
  94. data/lib/sxn/templates/template_variables.rb +713 -0
  95. data/lib/sxn/templates.rb +28 -0
  96. data/lib/sxn/ui/output.rb +103 -0
  97. data/lib/sxn/ui/progress_bar.rb +91 -0
  98. data/lib/sxn/ui/prompt.rb +116 -0
  99. data/lib/sxn/ui/table.rb +183 -0
  100. data/lib/sxn/ui.rb +12 -0
  101. data/lib/sxn/version.rb +5 -0
  102. data/lib/sxn.rb +63 -0
  103. data/rbs_collection.lock.yaml +180 -0
  104. data/rbs_collection.yaml +39 -0
  105. data/scripts/test.sh +31 -0
  106. data/sig/external/liquid.rbs +116 -0
  107. data/sig/external/thor.rbs +99 -0
  108. data/sig/external/tty.rbs +71 -0
  109. data/sig/sxn/cli.rbs +46 -0
  110. data/sig/sxn/commands/init.rbs +38 -0
  111. data/sig/sxn/commands/projects.rbs +72 -0
  112. data/sig/sxn/commands/rules.rbs +95 -0
  113. data/sig/sxn/commands/sessions.rbs +62 -0
  114. data/sig/sxn/commands/worktrees.rbs +82 -0
  115. data/sig/sxn/commands.rbs +6 -0
  116. data/sig/sxn/config/config_cache.rbs +67 -0
  117. data/sig/sxn/config/config_discovery.rbs +64 -0
  118. data/sig/sxn/config/config_validator.rbs +64 -0
  119. data/sig/sxn/config.rbs +74 -0
  120. data/sig/sxn/core/config_manager.rbs +67 -0
  121. data/sig/sxn/core/project_manager.rbs +52 -0
  122. data/sig/sxn/core/rules_manager.rbs +54 -0
  123. data/sig/sxn/core/session_manager.rbs +59 -0
  124. data/sig/sxn/core/worktree_manager.rbs +50 -0
  125. data/sig/sxn/core.rbs +87 -0
  126. data/sig/sxn/database/errors.rbs +37 -0
  127. data/sig/sxn/database/session_database.rbs +151 -0
  128. data/sig/sxn/database.rbs +83 -0
  129. data/sig/sxn/errors.rbs +89 -0
  130. data/sig/sxn/rules/base_rule.rbs +137 -0
  131. data/sig/sxn/rules/copy_files_rule.rbs +65 -0
  132. data/sig/sxn/rules/errors.rbs +33 -0
  133. data/sig/sxn/rules/project_detector.rbs +115 -0
  134. data/sig/sxn/rules/rules_engine.rbs +118 -0
  135. data/sig/sxn/rules/setup_commands_rule.rbs +60 -0
  136. data/sig/sxn/rules/template_rule.rbs +44 -0
  137. data/sig/sxn/rules.rbs +287 -0
  138. data/sig/sxn/runtime_validations.rbs +16 -0
  139. data/sig/sxn/security/secure_command_executor.rbs +63 -0
  140. data/sig/sxn/security/secure_file_copier.rbs +79 -0
  141. data/sig/sxn/security/secure_path_validator.rbs +30 -0
  142. data/sig/sxn/security.rbs +128 -0
  143. data/sig/sxn/templates/errors.rbs +43 -0
  144. data/sig/sxn/templates/template_engine.rbs +50 -0
  145. data/sig/sxn/templates/template_processor.rbs +44 -0
  146. data/sig/sxn/templates/template_security.rbs +62 -0
  147. data/sig/sxn/templates/template_variables.rbs +103 -0
  148. data/sig/sxn/templates.rbs +104 -0
  149. data/sig/sxn/ui/output.rbs +50 -0
  150. data/sig/sxn/ui/progress_bar.rbs +39 -0
  151. data/sig/sxn/ui/prompt.rbs +38 -0
  152. data/sig/sxn/ui/table.rbs +43 -0
  153. data/sig/sxn/ui.rbs +63 -0
  154. data/sig/sxn/version.rbs +5 -0
  155. data/sig/sxn.rbs +29 -0
  156. 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