caruso 0.5.4 → 0.6.2

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.
data/lib/caruso/cli.rb CHANGED
@@ -20,8 +20,10 @@ module Caruso
20
20
  fetcher = Caruso::Fetcher.new(url, ref: options[:ref])
21
21
 
22
22
  # For Git repos, clone/update the cache (skip in test mode to allow fake URLs)
23
- if (source == "github" || url.match?(/\Ahttps?:/) || url.match?(%r{[^/]+/[^/]+})) && !ENV["CARUSO_TESTING_SKIP_CLONE"]
24
- fetcher.clone_git_repo({"url" => url, "source" => source})
23
+ # Fixed ReDoS: Use anchored regex and limit input length to prevent catastrophic backtracking
24
+ is_owner_repo = url.length < 256 && url.match?(%r{\A[^/]+/[^/]+\z})
25
+ if (source == "github" || url.match?(/\Ahttps?:/) || is_owner_repo) && !ENV["CARUSO_TESTING_SKIP_CLONE"]
26
+ fetcher.clone_git_repo({ "url" => url, "source" => source })
25
27
  end
26
28
 
27
29
  # Read marketplace name from marketplace.json
@@ -49,27 +51,30 @@ module Caruso
49
51
  end
50
52
  end
51
53
 
52
- desc "remove NAME", "Remove a marketplace"
53
- def remove(name)
54
+ desc "remove NAME_OR_URL", "Remove a marketplace"
55
+ def remove(name_or_url)
54
56
  config_manager = load_config
57
+ marketplaces = config_manager.list_marketplaces
55
58
 
56
- # Remove from config
57
- config_manager.remove_marketplace(name)
58
-
59
- # Remove from registry
60
- registry = Caruso::MarketplaceRegistry.new
61
- marketplace = registry.get_marketplace(name)
62
- if marketplace
63
- cache_dir = marketplace["install_location"]
64
- registry.remove_marketplace(name)
65
-
66
- # Inform about cache directory
67
- if Dir.exist?(cache_dir)
68
- puts "Cache directory still exists at: #{cache_dir}"
69
- puts "Run 'rm -rf #{cache_dir}' to delete it if desired."
59
+ # Try to find by name first
60
+ if marketplaces.key?(name_or_url)
61
+ name = name_or_url
62
+ else
63
+ # Try to find by URL
64
+ # We need to check exact match or maybe normalized match
65
+ match = marketplaces.find { |_, details| details["url"] == name_or_url || details["url"].chomp(".git") == name_or_url }
66
+ if match
67
+ name = match[0]
68
+ else
69
+ puts "Marketplace '#{name_or_url}' not found."
70
+ return
70
71
  end
71
72
  end
72
73
 
74
+ # Use Remover to handle cleanup
75
+ remover = Caruso::Remover.new(config_manager)
76
+ remover.remove_marketplace(name)
77
+
73
78
  puts "Removed marketplace '#{name}'"
74
79
  end
75
80
 
@@ -86,14 +91,14 @@ module Caruso
86
91
  end
87
92
 
88
93
  puts "Marketplace: #{name}"
89
- puts " Source: #{marketplace['source']}" if marketplace['source']
94
+ puts " Source: #{marketplace['source']}" if marketplace["source"]
90
95
  puts " URL: #{marketplace['url']}"
91
96
  puts " Location: #{marketplace['install_location']}"
92
97
  puts " Last Updated: #{marketplace['last_updated']}"
93
- puts " Ref: #{marketplace['ref']}" if marketplace['ref']
98
+ puts " Ref: #{marketplace['ref']}" if marketplace["ref"]
94
99
 
95
100
  # Check if directory actually exists
96
- if Dir.exist?(marketplace['install_location'])
101
+ if Dir.exist?(marketplace["install_location"])
97
102
  puts " Status: ✓ Cached locally"
98
103
  else
99
104
  puts " Status: ✗ Cache directory missing"
@@ -105,12 +110,12 @@ module Caruso
105
110
  config_manager = load_config
106
111
  marketplaces = config_manager.list_marketplaces
107
112
 
113
+ if marketplaces.empty?
114
+ puts "No marketplaces configured. Use 'caruso marketplace add <url>' to get started."
115
+ return
116
+ end
108
117
  if name
109
118
  # Update specific marketplace
110
- if marketplaces.empty?
111
- puts "No marketplaces configured. Use 'caruso marketplace add <url>' to get started."
112
- return
113
- end
114
119
 
115
120
  marketplace_details = config_manager.get_marketplace_details(name)
116
121
  unless marketplace_details
@@ -121,7 +126,8 @@ module Caruso
121
126
 
122
127
  puts "Updating marketplace '#{name}'..."
123
128
  begin
124
- fetcher = Caruso::Fetcher.new(marketplace_details["url"], marketplace_name: name, ref: marketplace_details["ref"])
129
+ fetcher = Caruso::Fetcher.new(marketplace_details["url"], marketplace_name: name,
130
+ ref: marketplace_details["ref"])
125
131
  fetcher.update_cache
126
132
  puts "Updated marketplace '#{name}'"
127
133
  rescue StandardError => e
@@ -129,25 +135,19 @@ module Caruso
129
135
  end
130
136
  else
131
137
  # Update all marketplaces
132
- if marketplaces.empty?
133
- puts "No marketplaces configured. Use 'caruso marketplace add <url>' to get started."
134
- return
135
- end
136
138
 
137
139
  puts "Updating all marketplaces..."
138
140
  success_count = 0
139
141
  error_count = 0
140
142
 
141
143
  marketplaces.each do |marketplace_name, details|
142
- begin
143
- puts " Updating #{marketplace_name}..."
144
- fetcher = Caruso::Fetcher.new(details["url"], marketplace_name: marketplace_name, ref: details["ref"])
145
- fetcher.update_cache
146
- success_count += 1
147
- rescue StandardError => e
148
- puts " Error updating #{marketplace_name}: #{e.message}"
149
- error_count += 1
150
- end
144
+ puts " Updating #{marketplace_name}..."
145
+ fetcher = Caruso::Fetcher.new(details["url"], marketplace_name: marketplace_name, ref: details["ref"])
146
+ fetcher.update_cache
147
+ success_count += 1
148
+ rescue StandardError => e
149
+ puts " Error updating #{marketplace_name}: #{e.message}"
150
+ error_count += 1
151
151
  end
152
152
 
153
153
  puts "\nUpdated #{success_count} marketplace(s)" + (error_count.positive? ? " (#{error_count} failed)" : "")
@@ -263,15 +263,9 @@ module Caruso
263
263
  end
264
264
 
265
265
  puts "Removing #{plugin_key}..."
266
- files_to_remove = config_manager.remove_plugin(plugin_key)
267
266
 
268
- files_to_remove.each do |file|
269
- full_path = File.join(config_manager.project_dir, file)
270
- if File.exist?(full_path)
271
- File.delete(full_path)
272
- puts " Deleted #{file}"
273
- end
274
- end
267
+ remover = Caruso::Remover.new(config_manager)
268
+ remover.remove_plugin(plugin_key)
275
269
 
276
270
  puts "Uninstalled #{plugin_key}."
277
271
  end
@@ -323,14 +317,12 @@ module Caruso
323
317
  error_count = 0
324
318
 
325
319
  installed_plugins.each do |key, plugin_data|
326
- begin
327
- puts " Updating #{key}..."
328
- update_single_plugin(key, plugin_data, config_manager)
329
- success_count += 1
330
- rescue StandardError => e
331
- puts " Error updating #{key}: #{e.message}"
332
- error_count += 1
333
- end
320
+ puts " Updating #{key}..."
321
+ update_single_plugin(key, plugin_data, config_manager)
322
+ success_count += 1
323
+ rescue StandardError => e
324
+ puts " Error updating #{key}: #{e.message}"
325
+ error_count += 1
334
326
  end
335
327
 
336
328
  puts "\nUpdated #{success_count} plugin(s)" + (error_count.positive? ? " (#{error_count} failed)" : "")
@@ -377,7 +369,7 @@ module Caruso
377
369
  desc "outdated", "Show plugins with available updates"
378
370
  def outdated
379
371
  config_manager = load_config
380
- target_dir = config_manager.full_target_path
372
+ config_manager.full_target_path
381
373
 
382
374
  installed_plugins = config_manager.list_plugins
383
375
 
@@ -389,7 +381,7 @@ module Caruso
389
381
  puts "Checking for updates..."
390
382
  outdated_plugins = []
391
383
 
392
- marketplaces = config_manager.list_marketplaces
384
+ config_manager.list_marketplaces
393
385
 
394
386
  installed_plugins.each do |key, plugin_data|
395
387
  marketplace_name = plugin_data["marketplace"]
@@ -399,7 +391,8 @@ module Caruso
399
391
  next unless marketplace_details
400
392
 
401
393
  begin
402
- fetcher = Caruso::Fetcher.new(marketplace_details["url"], marketplace_name: marketplace_name, ref: marketplace_details["ref"])
394
+ Caruso::Fetcher.new(marketplace_details["url"], marketplace_name: marketplace_name,
395
+ ref: marketplace_details["ref"])
403
396
  # For now, we'll just report that updates might be available
404
397
  # Full version comparison would require version tracking in marketplace.json
405
398
  outdated_plugins << {
@@ -438,7 +431,8 @@ module Caruso
438
431
  end
439
432
 
440
433
  # Update marketplace cache first
441
- fetcher = Caruso::Fetcher.new(marketplace_details["url"], marketplace_name: marketplace_name, ref: marketplace_details["ref"])
434
+ fetcher = Caruso::Fetcher.new(marketplace_details["url"], marketplace_name: marketplace_name,
435
+ ref: marketplace_details["ref"])
442
436
  fetcher.update_cache
443
437
 
444
438
  # Parse plugin name from key (plugin@marketplace)
@@ -150,6 +150,23 @@ module Caruso
150
150
  save_project_config(data)
151
151
  end
152
152
 
153
+ def remove_marketplace_with_plugins(marketplace_name)
154
+ files_to_remove = []
155
+
156
+ # Find and remove all plugins associated with this marketplace
157
+ installed_plugins = list_plugins
158
+ installed_plugins.each do |plugin_key, details|
159
+ if details["marketplace"] == marketplace_name
160
+ files_to_remove.concat(remove_plugin(plugin_key))
161
+ end
162
+ end
163
+
164
+ # Remove the marketplace itself
165
+ remove_marketplace(marketplace_name)
166
+
167
+ files_to_remove.uniq
168
+ end
169
+
153
170
  def list_marketplaces
154
171
  load_project_config["marketplaces"] || {}
155
172
  end
@@ -167,6 +184,7 @@ module Caruso
167
184
 
168
185
  def load_project_config
169
186
  return {} unless File.exist?(@project_config_path)
187
+
170
188
  JSON.parse(File.read(@project_config_path))
171
189
  rescue JSON::ParserError
172
190
  {}
@@ -178,6 +196,7 @@ module Caruso
178
196
 
179
197
  def load_local_config
180
198
  return {} unless File.exist?(@local_config_path)
199
+
181
200
  JSON.parse(File.read(@local_config_path))
182
201
  rescue JSON::ParserError
183
202
  {}
@@ -5,6 +5,9 @@ require "faraday"
5
5
  require "fileutils"
6
6
  require "uri"
7
7
  require "git"
8
+ require_relative "safe_file"
9
+ require_relative "safe_dir"
10
+ require_relative "path_sanitizer"
8
11
 
9
12
  module Caruso
10
13
  class Fetcher
@@ -49,7 +52,7 @@ module Caruso
49
52
  end
50
53
 
51
54
  def update_cache
52
- return unless Dir.exist?(cache_dir)
55
+ return unless SafeDir.exist?(cache_dir)
53
56
 
54
57
  Dir.chdir(cache_dir) do
55
58
  git = Git.open(".")
@@ -61,7 +64,7 @@ module Caruso
61
64
  end
62
65
 
63
66
  @registry.update_timestamp(@marketplace_name)
64
- rescue => e
67
+ rescue StandardError => e
65
68
  handle_git_error(e)
66
69
  end
67
70
 
@@ -69,10 +72,10 @@ module Caruso
69
72
  url = source_config["url"] || source_config["repo"]
70
73
  url = "https://github.com/#{url}.git" if source_config["source"] == "github" && !url.match?(/\Ahttps?:/)
71
74
 
72
- repo_name = URI.parse(url).path.split("/").last.sub(".git", "")
75
+ URI.parse(url).path.split("/").last.sub(".git", "")
73
76
  target_path = cache_dir
74
77
 
75
- unless Dir.exist?(target_path)
78
+ unless SafeDir.exist?(target_path)
76
79
  # Clone the repository
77
80
  FileUtils.mkdir_p(File.dirname(target_path))
78
81
  Git.clone(url, target_path)
@@ -105,7 +108,7 @@ module Caruso
105
108
  def load_marketplace
106
109
  if local_path?
107
110
  # If marketplace_uri is a directory, find marketplace.json in it
108
- if File.directory?(@marketplace_uri)
111
+ if SafeDir.exist?(@marketplace_uri)
109
112
  json_path = File.join(@marketplace_uri, ".claude-plugin", "marketplace.json")
110
113
  json_path = File.join(@marketplace_uri, "marketplace.json") unless File.exist?(json_path)
111
114
 
@@ -122,7 +125,7 @@ module Caruso
122
125
  @base_dir = File.dirname(@base_dir)
123
126
  end
124
127
 
125
- JSON.parse(File.read(@marketplace_uri))
128
+ JSON.parse(SafeFile.read(@marketplace_uri))
126
129
  elsif github_repo?
127
130
  # Clone repo and read marketplace.json from it
128
131
  repo_path = clone_git_repo("url" => @marketplace_uri, "source" => "github")
@@ -138,7 +141,7 @@ module Caruso
138
141
  @marketplace_uri = json_path
139
142
  @base_dir = repo_path # Base dir is the repo root, regardless of where json is
140
143
 
141
- JSON.parse(File.read(json_path))
144
+ JSON.parse(SafeFile.read(json_path))
142
145
  else
143
146
  response = Faraday.get(@marketplace_uri)
144
147
  JSON.parse(response.body)
@@ -146,6 +149,10 @@ module Caruso
146
149
  end
147
150
 
148
151
  def github_repo?
152
+ # Fixed ReDoS: Add length check to prevent catastrophic backtracking
153
+ # GitHub URLs and owner/repo format should be reasonably short
154
+ return false if @marketplace_uri.length > 512
155
+
149
156
  @marketplace_uri.match?(%r{\Ahttps://github\.com/[^/]+/[^/]+}) ||
150
157
  @marketplace_uri.match?(%r{\A[^/]+/[^/]+\z}) # owner/repo format
151
158
  end
@@ -153,7 +160,25 @@ module Caruso
153
160
  def fetch_plugin(plugin)
154
161
  source = plugin["source"]
155
162
  plugin_path = resolve_plugin_path(source)
156
- return [] unless plugin_path && Dir.exist?(plugin_path)
163
+ return [] unless plugin_path
164
+
165
+ # Validate that plugin_path is safe before using it
166
+ # resolve_plugin_path returns paths from trusted sources:
167
+ # - cache_dir (under ~/.caruso/marketplaces/)
168
+ # - validated local paths relative to @base_dir
169
+ # However, we still validate to ensure no path traversal
170
+ begin
171
+ # For paths under ~/.caruso, validate against home directory
172
+ # For local paths, they're already validated in resolve_plugin_path
173
+ if plugin_path.start_with?(File.join(Dir.home, ".caruso"))
174
+ PathSanitizer.sanitize_path(plugin_path, base_dir: File.join(Dir.home, ".caruso"))
175
+ end
176
+ rescue PathSanitizer::PathTraversalError => e
177
+ warn "Invalid plugin path '#{plugin_path}': #{e.message}"
178
+ return []
179
+ end
180
+
181
+ return [] unless SafeDir.exist?(plugin_path)
157
182
 
158
183
  # Start with default directories
159
184
  files = find_steering_files(plugin_path)
@@ -186,7 +211,12 @@ module Caruso
186
211
  end
187
212
 
188
213
  def find_steering_files(plugin_path)
189
- Dir.glob(File.join(plugin_path, "{commands,agents,skills}/**/*.md")).reject do |file|
214
+ # Validate plugin_path before using it in glob
215
+ # This is safe because plugin_path comes from resolve_plugin_path which returns trusted paths
216
+ # (either from cache_dir which is under ~/.caruso, or validated local paths)
217
+ glob_pattern = PathSanitizer.safe_join(plugin_path, "{commands,agents,skills}", "**", "*.md")
218
+
219
+ SafeDir.glob(glob_pattern, base_dir: plugin_path).reject do |file|
190
220
  basename = File.basename(file).downcase
191
221
  ["readme.md", "license.md"].include?(basename)
192
222
  end
@@ -199,16 +229,23 @@ module Caruso
199
229
 
200
230
  files = []
201
231
  paths.each do |path|
202
- # Resolve the path relative to plugin_path
203
- full_path = File.expand_path(path, plugin_path)
232
+ # Resolve and sanitize the path relative to plugin_path
233
+ # This ensures the path stays within plugin_path boundaries
234
+ begin
235
+ full_path = PathSanitizer.sanitize_path(File.expand_path(path, plugin_path), base_dir: plugin_path)
236
+ rescue PathSanitizer::PathTraversalError => e
237
+ warn "Skipping path outside plugin directory '#{path}': #{e.message}"
238
+ next
239
+ end
204
240
 
205
241
  # Handle both files and directories
206
242
  if File.file?(full_path) && full_path.end_with?(".md")
207
243
  basename = File.basename(full_path).downcase
208
244
  files << full_path unless ["readme.md", "license.md"].include?(basename)
209
- elsif Dir.exist?(full_path)
210
- # Find all .md files in this directory
211
- Dir.glob(File.join(full_path, "**/*.md")).each do |file|
245
+ elsif SafeDir.exist?(full_path, base_dir: plugin_path)
246
+ # Find all .md files in this directory using safe_join
247
+ glob_pattern = PathSanitizer.safe_join(full_path, "**", "*.md")
248
+ SafeDir.glob(glob_pattern, base_dir: plugin_path).each do |file|
212
249
  basename = File.basename(file).downcase
213
250
  files << file unless ["readme.md", "license.md"].include?(basename)
214
251
  end
@@ -15,7 +15,7 @@ module Caruso
15
15
  def add_marketplace(name, url, install_location, ref: nil, source: "git")
16
16
  data = load_registry
17
17
  data[name] = {
18
- "source" => source, # github, git, url, local, directory
18
+ "source" => source, # github, git, url, local, directory
19
19
  "url" => url,
20
20
  "install_location" => install_location,
21
21
  "last_updated" => Time.now.iso8601,
@@ -51,7 +51,7 @@ module Caruso
51
51
 
52
52
  # Enhancement #3: Schema validation
53
53
  def validate_marketplace_entry(entry)
54
- required = ["url", "install_location", "last_updated"]
54
+ required = %w[url install_location last_updated]
55
55
  missing = required - entry.keys
56
56
 
57
57
  unless missing.empty?
@@ -71,7 +71,7 @@ module Caruso
71
71
  data = JSON.parse(File.read(@registry_path))
72
72
 
73
73
  # Validate each entry
74
- data.each do |name, entry|
74
+ data.each_value do |entry|
75
75
  validate_marketplace_entry(entry)
76
76
  end
77
77
 
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Caruso
6
+ # PathSanitizer provides utilities to validate and sanitize file paths
7
+ # to prevent path traversal attacks and ensure paths stay within expected boundaries
8
+ module PathSanitizer
9
+ class PathTraversalError < StandardError; end
10
+
11
+ # Validates that a path doesn't contain path traversal sequences
12
+ # and stays within the expected base directory
13
+ #
14
+ # @param path [String] The path to validate
15
+ # @param base_dir [String] Optional base directory to validate against
16
+ # @return [String] The validated, normalized path
17
+ # @raise [PathTraversalError] if path contains traversal sequences or escapes base_dir
18
+ def self.sanitize_path(path, base_dir: nil)
19
+ return nil if path.nil? || path.empty?
20
+
21
+ # Normalize the path to resolve any . or .. components
22
+ normalized_path = Pathname.new(path).expand_path
23
+
24
+ # If base_dir is provided, ensure the path stays within it
25
+ if base_dir
26
+ normalized_base = Pathname.new(base_dir).expand_path
27
+
28
+ # Check if path is within base using relative_path_from
29
+ # This will raise ArgumentError if path is not within base
30
+ begin
31
+ relative = normalized_path.relative_path_from(normalized_base)
32
+ # Check that the relative path doesn't start with ..
33
+ if relative.to_s.start_with?("..")
34
+ raise PathTraversalError, "Path escapes base directory: #{path}"
35
+ end
36
+ rescue ArgumentError
37
+ # Paths on different drives/volumes
38
+ raise PathTraversalError, "Path is not within base directory: #{path}"
39
+ end
40
+ end
41
+
42
+ normalized_path.to_s
43
+ end
44
+
45
+ # Safely joins path components, ensuring no traversal attacks
46
+ #
47
+ # @param base [String] The base directory
48
+ # @param *parts [String] Path components to join
49
+ # @return [String] The sanitized joined path
50
+ # @raise [PathTraversalError] if result would escape base directory
51
+ def self.safe_join(base, *parts)
52
+ # Join the parts
53
+ joined = File.join(base, *parts)
54
+
55
+ # Validate the result stays within base
56
+ sanitize_path(joined, base_dir: base)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "marketplace_registry"
4
+
5
+ module Caruso
6
+ class Remover
7
+ attr_reader :config_manager
8
+
9
+ def initialize(config_manager)
10
+ @config_manager = config_manager
11
+ end
12
+
13
+ def remove_marketplace(name)
14
+ # 1. Remove from config and get associated plugin files
15
+ # This updates both project config (plugins) and local config (files)
16
+ files_to_remove = config_manager.remove_marketplace_with_plugins(name)
17
+
18
+ # 2. Delete the actual files
19
+ delete_files(files_to_remove)
20
+
21
+ # 3. Clean up registry cache
22
+ remove_from_registry(name)
23
+ end
24
+
25
+ def remove_plugin(name)
26
+ # 1. Remove from config
27
+ files_to_remove = config_manager.remove_plugin(name)
28
+
29
+ # 2. Delete files
30
+ delete_files(files_to_remove)
31
+ end
32
+
33
+ private
34
+
35
+ def delete_files(files)
36
+ files.each do |file|
37
+ full_path = File.join(config_manager.project_dir, file)
38
+ if File.exist?(full_path)
39
+ File.delete(full_path)
40
+ puts " Deleted #{file}"
41
+ end
42
+ end
43
+ end
44
+
45
+ def remove_from_registry(name)
46
+ registry = Caruso::MarketplaceRegistry.new
47
+ marketplace = registry.get_marketplace(name)
48
+ return unless marketplace
49
+
50
+ cache_dir = marketplace["install_location"]
51
+ registry.remove_marketplace(name)
52
+
53
+ # Inform about cache directory
54
+ return unless Dir.exist?(cache_dir)
55
+
56
+ puts "Cache directory still exists at: #{cache_dir}"
57
+ puts "Run 'rm -rf #{cache_dir}' to delete it if desired."
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "path_sanitizer"
4
+
5
+ module Caruso
6
+ class SafeDir
7
+ class Error < StandardError; end
8
+ class SecurityError < Error; end
9
+
10
+ # Safely checks if a directory exists
11
+ #
12
+ # @param path [String] The path to check
13
+ # @param base_dir [String] Optional base directory to restrict access to
14
+ # @return [Boolean] true if directory exists and is safe
15
+ def self.exist?(path, base_dir: nil)
16
+ safe_path = sanitize(path, base_dir)
17
+ Dir.exist?(safe_path)
18
+ rescue SecurityError
19
+ false
20
+ end
21
+
22
+ # Safely globs files
23
+ #
24
+ # @param pattern [String] The glob pattern
25
+ # @param base_dir [String] Optional base directory to restrict access to
26
+ # @return [Array<String>] Matching file paths
27
+ def self.glob(pattern, base_dir: nil)
28
+ # For glob, we need to be careful. Ideally we validate the base of the pattern.
29
+ # If pattern contains wildcards, we can't easily sanitize the whole string as a path.
30
+ # So we rely on the caller to have constructed the pattern safely (e.g. using safe_join).
31
+ # But we can still check if the resolved paths are safe if base_dir is provided.
32
+
33
+ Dir.glob(pattern).select do |path|
34
+ if base_dir
35
+ begin
36
+ PathSanitizer.sanitize_path(path, base_dir: base_dir)
37
+ true
38
+ rescue PathSanitizer::PathTraversalError
39
+ false
40
+ end
41
+ else
42
+ true
43
+ end
44
+ end
45
+ end
46
+
47
+ def self.sanitize(path, base_dir)
48
+ PathSanitizer.sanitize_path(path, base_dir: base_dir)
49
+ rescue PathSanitizer::PathTraversalError => e
50
+ raise SecurityError, "Invalid directory path: #{e.message}"
51
+ end
52
+ private_class_method :sanitize
53
+ end
54
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "path_sanitizer"
4
+
5
+ module Caruso
6
+ class SafeFile
7
+ class Error < StandardError; end
8
+ class NotFoundError < Error; end
9
+ class SecurityError < Error; end
10
+
11
+ # Safely reads a file from the filesystem
12
+ #
13
+ # @param path [String] The path to the file to read
14
+ # @param base_dir [String] Optional base directory to restrict access to
15
+ # @return [String] The file content
16
+ # @raise [NotFoundError] if the file does not exist or is not a file
17
+ # @raise [SecurityError] if the path is unsafe or outside base_dir
18
+ def self.read(path, base_dir: nil)
19
+ # 1. Sanitize and validate path
20
+ begin
21
+ safe_path = PathSanitizer.sanitize_path(path, base_dir: base_dir)
22
+ rescue PathSanitizer::PathTraversalError => e
23
+ raise SecurityError, "Invalid file path: #{e.message}"
24
+ end
25
+
26
+ # 2. Check existence and type
27
+ unless File.exist?(safe_path)
28
+ raise NotFoundError, "File not found: #{path}"
29
+ end
30
+
31
+ unless File.file?(safe_path)
32
+ raise NotFoundError, "Path is not a regular file: #{path}"
33
+ end
34
+
35
+ # 3. Read content
36
+ File.read(safe_path)
37
+ end
38
+ end
39
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Caruso
4
- VERSION = "0.5.4"
4
+ VERSION = "0.6.2"
5
5
  end
data/lib/caruso.rb CHANGED
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "caruso/safe_file"
4
+ require_relative "caruso/safe_dir"
3
5
  require_relative "caruso/version"
4
6
  require_relative "caruso/config_manager"
5
7
  require_relative "caruso/fetcher"
6
8
  require_relative "caruso/adapter"
7
9
  require_relative "caruso/marketplace_registry"
10
+ require_relative "caruso/remover"
8
11
 
9
12
  require_relative "caruso/cli"
10
13