tree_haver 7.0.0 → 7.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/LICENSE.md +13 -0
  4. data/README.md +1959 -0
  5. data/lib/tree_haver/backend_api.rb +392 -0
  6. data/lib/tree_haver/backend_registry.rb +153 -3
  7. data/lib/tree_haver/backends/citrus.rb +489 -0
  8. data/lib/tree_haver/backends/ffi.rb +1013 -0
  9. data/lib/tree_haver/backends/java.rb +909 -0
  10. data/lib/tree_haver/backends/mri.rb +367 -0
  11. data/lib/tree_haver/backends/parslet.rb +565 -0
  12. data/lib/tree_haver/backends/prism.rb +568 -0
  13. data/lib/tree_haver/backends/psych.rb +379 -0
  14. data/lib/tree_haver/backends/rust.rb +243 -0
  15. data/lib/tree_haver/backends/tslp.rb +274 -0
  16. data/lib/tree_haver/base/comment.rb +320 -0
  17. data/lib/tree_haver/base/language.rb +98 -0
  18. data/lib/tree_haver/base/node.rb +330 -0
  19. data/lib/tree_haver/base/parser.rb +28 -0
  20. data/lib/tree_haver/base/point.rb +48 -0
  21. data/lib/tree_haver/base/tree.rb +128 -0
  22. data/lib/tree_haver/citrus_grammar_finder.rb +213 -0
  23. data/lib/tree_haver/contracts.rb +661 -96
  24. data/lib/tree_haver/grammar_finder.rb +429 -0
  25. data/lib/tree_haver/kaitai_backend.rb +2 -2
  26. data/lib/tree_haver/language.rb +294 -0
  27. data/lib/tree_haver/language_pack.rb +17 -166
  28. data/lib/tree_haver/language_registry.rb +221 -0
  29. data/lib/tree_haver/library_path_utils.rb +80 -0
  30. data/lib/tree_haver/node.rb +588 -0
  31. data/lib/tree_haver/parser.rb +445 -0
  32. data/lib/tree_haver/parslet_grammar_finder.rb +217 -0
  33. data/lib/tree_haver/path_validator.rb +356 -0
  34. data/lib/tree_haver/peg_backends.rb +7 -7
  35. data/lib/tree_haver/point.rb +27 -0
  36. data/lib/tree_haver/rspec/dependency_tags.rb +52 -0
  37. data/lib/tree_haver/rspec.rb +3 -0
  38. data/lib/tree_haver/tree.rb +267 -0
  39. data/lib/tree_haver/version.rb +5 -3
  40. data/lib/tree_haver.rb +613 -8
  41. data/sig/tree_haver.rbs +6 -0
  42. data.tar.gz.sig +0 -0
  43. metadata +314 -13
  44. metadata.gz.sig +0 -0
@@ -0,0 +1,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TreeHaver
4
+ # Security utilities for validating paths and inputs before loading shared libraries.
5
+ #
6
+ # Loading shared libraries (.so/.dylib/.dll) is inherently dangerous as it executes
7
+ # arbitrary native code. This module provides defense-in-depth validations to reduce
8
+ # the attack surface when paths come from potentially untrusted sources like
9
+ # environment variables or user input.
10
+ #
11
+ # @example Validate a path before loading
12
+ # path = ENV["TREE_SITTER_TOML_PATH"]
13
+ # if TreeHaver::PathValidator.safe_library_path?(path)
14
+ # language = TreeHaver::Language.from_library(path)
15
+ # else
16
+ # raise "Unsafe path: #{path}"
17
+ # end
18
+ #
19
+ # @example Register custom trusted directories
20
+ # # For Homebrew on Linux (linuxbrew)
21
+ # TreeHaver::PathValidator.add_trusted_directory("/home/linuxbrew/.linuxbrew/Cellar")
22
+ #
23
+ # # For luarocks-installed grammars
24
+ # TreeHaver::PathValidator.add_trusted_directory("~/.local/share/mise/installs/lua")
25
+ #
26
+ # # Or via environment variable (comma-separated)
27
+ # # export TREE_HAVER_TRUSTED_DIRS="/home/linuxbrew/.linuxbrew/Cellar,~/.local/share/mise"
28
+ #
29
+ # @note These validations provide defense-in-depth but cannot guarantee safety.
30
+ # Loading shared libraries from untrusted sources is always risky.
31
+ module PathValidator
32
+ # Allowed shared library extensions by platform
33
+ ALLOWED_EXTENSIONS = %w[.so .dylib .dll].freeze
34
+
35
+ # Default directories that are generally trusted for system libraries
36
+ # These are searched by the dynamic linker anyway
37
+ DEFAULT_TRUSTED_DIRECTORIES = [
38
+ '/usr/lib',
39
+ '/usr/lib64',
40
+ '/usr/lib/x86_64-linux-gnu',
41
+ '/usr/lib/aarch64-linux-gnu',
42
+ '/usr/local/lib',
43
+ '/opt/homebrew/lib',
44
+ '/opt/local/lib'
45
+ ].freeze
46
+
47
+ # Environment variable for adding trusted directories (comma-separated)
48
+ TRUSTED_DIRS_ENV_VAR = 'TREE_HAVER_TRUSTED_DIRS'
49
+
50
+ # Maximum reasonable path length (prevents DoS via extremely long paths)
51
+ MAX_PATH_LENGTH = 4096
52
+
53
+ # Pattern for valid library filenames (alphanumeric, hyphens, underscores, dots)
54
+ # This prevents shell metacharacters and other injection attempts
55
+ VALID_FILENAME_PATTERN = /\A[a-zA-Z0-9][a-zA-Z0-9._-]*\z/
56
+
57
+ # Pattern for valid language names (lowercase alphanumeric and underscores)
58
+ VALID_LANGUAGE_PATTERN = /\A[a-z][a-z0-9_]*\z/
59
+
60
+ # Pattern for valid symbol names (C identifier format)
61
+ VALID_SYMBOL_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
62
+
63
+ @custom_trusted_directories = []
64
+ @mutex = Mutex.new
65
+
66
+ module_function
67
+
68
+ # Get all trusted directories (default + user-local + custom + from ENV)
69
+ #
70
+ # @return [Array<String>] list of all trusted directory prefixes
71
+ def trusted_directories
72
+ dirs = DEFAULT_TRUSTED_DIRECTORIES.dup
73
+
74
+ # Add user-local XDG directories (computed at call time from HOME)
75
+ begin
76
+ home = Dir.home
77
+ dirs << File.join(home, '.local', 'lib', 'tree-sitter')
78
+ dirs << File.join(home, '.local', 'lib')
79
+ rescue ArgumentError
80
+ # HOME not set — skip user-local dirs
81
+ end
82
+
83
+ # Add custom registered directories
84
+ @mutex.synchronize { dirs.concat(@custom_trusted_directories) }
85
+
86
+ # Add directories from environment variable
87
+ ENV[TRUSTED_DIRS_ENV_VAR]&.split(',')&.each do |dir|
88
+ expanded = File.expand_path(dir.strip)
89
+ # simplecov:disable
90
+ # File.expand_path always returns absolute paths on Unix/macOS.
91
+ # This guard exists for defensive programming on exotic platforms
92
+ # where expand_path might behave differently, but cannot be tested
93
+ # in standard CI environments.
94
+ dirs << expanded if expanded.start_with?('/')
95
+ # simplecov:enable
96
+ end
97
+
98
+ dirs.uniq
99
+ end
100
+
101
+ # Register a custom trusted directory
102
+ #
103
+ # Use this to add directories where you install tree-sitter grammars,
104
+ # such as Homebrew locations, luarocks paths, or other package managers.
105
+ #
106
+ # @param directory [String] absolute path to trust (~ is expanded)
107
+ # @return [void]
108
+ # @raise [ArgumentError] if directory is not an absolute path
109
+ #
110
+ # @example Register linuxbrew directory
111
+ # TreeHaver::PathValidator.add_trusted_directory("/home/linuxbrew/.linuxbrew/Cellar")
112
+ #
113
+ # @example Register user's luarocks directory
114
+ # TreeHaver::PathValidator.add_trusted_directory("~/.local/share/mise/installs/lua")
115
+ def add_trusted_directory(directory)
116
+ expanded = File.expand_path(directory)
117
+
118
+ # simplecov:disable
119
+ # File.expand_path always returns absolute paths on Unix/macOS.
120
+ # This guard exists for defensive programming on exotic platforms
121
+ # where expand_path might behave differently, but cannot be tested
122
+ # in standard CI environments.
123
+ unless expanded.start_with?('/')
124
+ raise ArgumentError, "Trusted directory must be an absolute path: #{directory.inspect}"
125
+ end
126
+ # simplecov:enable
127
+
128
+ @mutex.synchronize do
129
+ @custom_trusted_directories << expanded unless @custom_trusted_directories.include?(expanded)
130
+ end
131
+ nil
132
+ end
133
+
134
+ # Remove a custom trusted directory
135
+ #
136
+ # @param directory [String] the directory to remove
137
+ # @return [void]
138
+ def remove_trusted_directory(directory)
139
+ expanded = File.expand_path(directory)
140
+ @mutex.synchronize { @custom_trusted_directories.delete(expanded) }
141
+ nil
142
+ end
143
+
144
+ # Clear all custom trusted directories
145
+ #
146
+ # Does not affect DEFAULT_TRUSTED_DIRECTORIES or ENV-based directories.
147
+ # Primarily useful for testing.
148
+ #
149
+ # @return [void]
150
+ def clear_custom_trusted_directories!
151
+ @mutex.synchronize { @custom_trusted_directories.clear }
152
+ nil
153
+ end
154
+
155
+ # Get the list of custom trusted directories (for debugging)
156
+ #
157
+ # @return [Array<String>] list of custom registered directories
158
+ def custom_trusted_directories
159
+ @mutex.synchronize { @custom_trusted_directories.dup }
160
+ end
161
+
162
+ # Validate a path is safe for loading as a shared library
163
+ #
164
+ # Checks performed:
165
+ # - Path is not nil or empty
166
+ # - Path length is reasonable
167
+ # - Path is absolute (no relative path traversal)
168
+ # - Path has an allowed extension
169
+ # - Path does not contain null bytes
170
+ # - Filename portion matches safe pattern
171
+ #
172
+ # @param path [String, nil] the path to validate
173
+ # @param require_trusted_dir [Boolean] if true, path must be in a trusted directory
174
+ # @return [Boolean] true if the path passes all safety checks
175
+ #
176
+ # @example
177
+ # PathValidator.safe_library_path?("/usr/lib/libtree-sitter-toml.so")
178
+ # # => true
179
+ #
180
+ # PathValidator.safe_library_path?("../../../tmp/evil.so")
181
+ # # => false
182
+ def safe_library_path?(path, require_trusted_dir: false)
183
+ return false if path.nil? || path.empty?
184
+ return false if path.length > MAX_PATH_LENGTH
185
+ return false if path.include?("\0") # Null byte injection
186
+
187
+ # Must be absolute path (prevents relative path traversal)
188
+ return false unless path.start_with?('/') || windows_absolute_path?(path)
189
+
190
+ # Check for path traversal attempts
191
+ return false if path.include?('/../') || path.end_with?('/..')
192
+ return false if path.include?('/./') || path.end_with?('/.')
193
+
194
+ # Validate extension
195
+ # Allow versioned .so files like .so.0, .so.14, etc. (common on Linux)
196
+ return false unless has_valid_extension?(path)
197
+
198
+ # Validate filename portion
199
+ filename = File.basename(path)
200
+ return false unless filename.match?(VALID_FILENAME_PATTERN)
201
+
202
+ # Optionally require the path to be in a trusted directory
203
+ return false if require_trusted_dir && !in_trusted_directory?(path)
204
+
205
+ true
206
+ end
207
+
208
+ # Check if a path is within a trusted directory
209
+ #
210
+ # Checks against DEFAULT_TRUSTED_DIRECTORIES, custom registered directories,
211
+ # and directories from TREE_HAVER_TRUSTED_DIRS environment variable.
212
+ #
213
+ # @param path [String] the path to check
214
+ # @return [Boolean] true if the path is in a trusted directory
215
+ def in_trusted_directory?(path)
216
+ return false if path.nil?
217
+
218
+ # Resolve the real path to handle symlinks
219
+ check_path = resolve_check_path(path)
220
+ return false if check_path.nil?
221
+
222
+ trusted_directories.any? { |trusted| check_path.start_with?(trusted) }
223
+ end
224
+
225
+ # Resolve a path to its real path for trust checking
226
+ #
227
+ # @param path [String] the path to resolve
228
+ # @return [String, nil] the resolved path or nil if unresolvable
229
+ # @api private
230
+ def resolve_check_path(path)
231
+ File.realpath(path)
232
+ rescue Errno::ENOENT
233
+ # File doesn't exist yet, check the directory
234
+ dir = File.dirname(path)
235
+ begin
236
+ File.realpath(dir)
237
+ rescue Errno::ENOENT
238
+ nil
239
+ end
240
+ end
241
+
242
+ # Validate a language name is safe
243
+ #
244
+ # Language names are used to construct:
245
+ # - Environment variable names (TREE_SITTER_<LANG>_PATH)
246
+ # - Library filenames (libtree-sitter-<lang>.so)
247
+ # - Symbol names (tree_sitter_<lang>)
248
+ #
249
+ # @param name [String, Symbol, nil] the language name to validate
250
+ # @return [Boolean] true if the name is safe
251
+ #
252
+ # @example
253
+ # PathValidator.safe_language_name?(:toml) # => true
254
+ # PathValidator.safe_language_name?("json") # => true
255
+ # PathValidator.safe_language_name?("../../etc") # => false
256
+ def safe_language_name?(name)
257
+ return false if name.nil?
258
+
259
+ name_str = name.to_s
260
+ return false if name_str.empty?
261
+ return false if name_str.length > 64 # Reasonable limit
262
+
263
+ name_str.match?(VALID_LANGUAGE_PATTERN)
264
+ end
265
+
266
+ # Validate a symbol name is safe for dlsym lookup
267
+ #
268
+ # @param symbol [String, nil] the symbol name to validate
269
+ # @return [Boolean] true if the symbol name is safe
270
+ #
271
+ # @example
272
+ # PathValidator.safe_symbol_name?("tree_sitter_toml") # => true
273
+ # PathValidator.safe_symbol_name?("evil; rm -rf /") # => false
274
+ def safe_symbol_name?(symbol)
275
+ return false if symbol.nil?
276
+ return false if symbol.empty?
277
+ return false if symbol.length > 256 # Reasonable limit
278
+
279
+ symbol.match?(VALID_SYMBOL_PATTERN)
280
+ end
281
+
282
+ # Validate a backend name
283
+ #
284
+ # @param backend [String, Symbol, nil] the backend name
285
+ # @return [Boolean] true if it's a valid backend name
286
+ def safe_backend_name?(backend)
287
+ return true if backend.nil? # nil means :auto
288
+
289
+ %i[auto mri rust ffi java].include?(backend.to_s.to_sym)
290
+ end
291
+
292
+ # Sanitize a language name for safe use
293
+ #
294
+ # @param name [String, Symbol] the language name
295
+ # @return [Symbol, nil] sanitized name or nil if invalid
296
+ #
297
+ # @example
298
+ # PathValidator.sanitize_language_name("TOML") # => :toml
299
+ # PathValidator.sanitize_language_name("c++") # => nil (invalid)
300
+ def sanitize_language_name(name)
301
+ return if name.nil?
302
+
303
+ sanitized = name.to_s.downcase.gsub(/[^a-z0-9_]/, '')
304
+ return if sanitized.empty?
305
+ return unless sanitized.match?(/\A[a-z]/)
306
+
307
+ sanitized.to_sym
308
+ end
309
+
310
+ # Get validation errors for a path (for debugging/error messages)
311
+ #
312
+ # @param path [String, nil] the path to validate
313
+ # @return [Array<String>] list of validation errors (empty if valid)
314
+ def validation_errors(path)
315
+ errors = []
316
+
317
+ if path.nil? || path.empty?
318
+ errors << 'Path is nil or empty'
319
+ return errors
320
+ end
321
+
322
+ errors << "Path exceeds maximum length (#{MAX_PATH_LENGTH})" if path.length > MAX_PATH_LENGTH
323
+ errors << 'Path contains null byte' if path.include?("\0")
324
+ errors << 'Path is not absolute' unless path.start_with?('/') || windows_absolute_path?(path)
325
+ errors << 'Path contains traversal sequence (/../)' if path.include?('/../') || path.end_with?('/..')
326
+ errors << 'Path contains traversal sequence (/./)' if path.include?('/./') || path.end_with?('/.')
327
+
328
+ errors << 'Path does not have allowed extension (.so, .so.X, .dylib, .dll)' unless has_valid_extension?(path)
329
+
330
+ filename = File.basename(path)
331
+ errors << 'Filename contains invalid characters' unless filename.match?(VALID_FILENAME_PATTERN)
332
+
333
+ errors
334
+ end
335
+
336
+ # @api private
337
+ def windows_absolute_path?(path)
338
+ # Match Windows absolute paths like C:\path or D:/path
339
+ path.match?(%r{\A[A-Za-z]:[\\/]})
340
+ end
341
+
342
+ # @api private
343
+ # Check if path has a valid library extension
344
+ # Allows: .so, .dylib, .dll, and versioned .so files like .so.0, .so.14
345
+ def has_valid_extension?(path)
346
+ # Check for exact matches first (.so, .dylib, .dll)
347
+ return true if ALLOWED_EXTENSIONS.any? { |ext| path.end_with?(ext) }
348
+
349
+ # Check for versioned .so files (Linux convention)
350
+ # e.g., libtree-sitter.so.0, libtree-sitter.so.14
351
+ return true if path.match?(/\.so\.\d+\z/)
352
+
353
+ false
354
+ end
355
+ end
356
+ end
@@ -2,13 +2,13 @@
2
2
 
3
3
  module TreeHaver
4
4
  CITRUS_BACKEND = BackendReference.new(
5
- id: "citrus",
6
- family: "peg"
5
+ id: 'citrus',
6
+ family: 'peg'
7
7
  ).freeze
8
8
 
9
9
  PARSLET_BACKEND = BackendReference.new(
10
- id: "parslet",
11
- family: "peg"
10
+ id: 'parslet',
11
+ family: 'peg'
12
12
  ).freeze
13
13
 
14
14
  BackendRegistry.register(CITRUS_BACKEND)
@@ -47,14 +47,14 @@ module TreeHaver
47
47
  {
48
48
  ok: false,
49
49
  backend_ref: CITRUS_BACKEND,
50
- diagnostics: [{ severity: "error", category: "parse_error", message: "Citrus parse failed." }]
50
+ diagnostics: [{ severity: 'error', category: 'parse_error', message: 'Citrus parse failed.' }]
51
51
  }
52
52
  end
53
53
  rescue StandardError => e
54
54
  {
55
55
  ok: false,
56
56
  backend_ref: CITRUS_BACKEND,
57
- diagnostics: [{ severity: "error", category: "parse_error", message: e.message }]
57
+ diagnostics: [{ severity: 'error', category: 'parse_error', message: e.message }]
58
58
  }
59
59
  end
60
60
 
@@ -70,7 +70,7 @@ module TreeHaver
70
70
  {
71
71
  ok: false,
72
72
  backend_ref: PARSLET_BACKEND,
73
- diagnostics: [{ severity: "error", category: "parse_error", message: e.message }]
73
+ diagnostics: [{ severity: 'error', category: 'parse_error', message: e.message }]
74
74
  }
75
75
  end
76
76
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TreeHaver
4
+ # Point class that works as both a Hash and an object with row/column accessors
5
+ #
6
+ # This provides compatibility with code expecting either:
7
+ # - Hash access: point[:row], point[:column]
8
+ # - Method access: point.row, point.column
9
+ #
10
+ # TreeHaver::Point is an alias for TreeHaver::Base::Point, which is a Struct
11
+ # providing all the necessary functionality.
12
+ #
13
+ # @example Method access
14
+ # point = TreeHaver::Point.new(5, 10)
15
+ # point.row # => 5
16
+ # point.column # => 10
17
+ #
18
+ # @example Hash-like access
19
+ # point[:row] # => 5
20
+ # point[:column] # => 10
21
+ #
22
+ # @example Converting to hash
23
+ # point.to_h # => {row: 5, column: 10}
24
+ #
25
+ # @see Base::Point The underlying Struct implementation
26
+ Point = Base::Point
27
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tree_haver'
4
+
5
+ module TreeHaver
6
+ module RSpec
7
+ module DependencyTags
8
+ class << self
9
+ def available?(tag_name)
10
+ TreeHaver::BackendRegistry.tag_available?(tag_name)
11
+ end
12
+
13
+ def summary
14
+ TreeHaver::BackendRegistry.tag_summary
15
+ end
16
+
17
+ def reset!
18
+ TreeHaver::BackendRegistry.clear_cache!
19
+ TreeHaver::BackendRegistry.registered_tags.each do |tag|
20
+ backend = TreeHaver::BackendRegistry.tag_metadata(tag)&.fetch(:backend_name, nil)
21
+ backend ||= TreeHaver::BackendRegistry.send(:inferred_backend_name, tag)
22
+ ivar = :"@#{backend}_available"
23
+ remove_instance_variable(ivar) if instance_variable_defined?(ivar)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ if defined?(::RSpec)
32
+ ::RSpec.configure do |config|
33
+ TreeHaver::BackendRegistry.registered_tags.each do |tag|
34
+ if TreeHaver::BackendRegistry.tag_available?(tag)
35
+ config.filter_run_excluding("not_#{tag}": true)
36
+ else
37
+ config.filter_run_excluding(tag => true)
38
+ end
39
+ end
40
+
41
+ config.before(:suite) do
42
+ next if ENV.fetch('TREE_HAVER_DEBUG', 'false').casecmp?('false')
43
+
44
+ puts "\n=== TreeHaver Test Dependencies ==="
45
+ TreeHaver::RSpec::DependencyTags.summary.each do |dep, available|
46
+ status = available ? 'available' : 'not available'
47
+ puts " #{dep}: #{status}"
48
+ end
49
+ puts "===================================\n"
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rspec/dependency_tags'