rubik 0.3.3 → 0.3.4

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.
@@ -34,101 +34,109 @@ module Rubik
34
34
  scope, clean_args = Base.parse_scope(args)
35
35
  sub = clean_args.first&.downcase || "list"
36
36
 
37
- handle_add = lambda do |tokens_to_add|
38
- entry_tokens = tokens_to_add.reject { |a| a == "--force" }
39
- entry = entry_tokens.join(" ").strip
40
- if entry.empty?
41
- ctx.out.puts 'Usage: !allow-cmd add "<command prefix>" [--project|--global] [--force]'
42
- return
43
- end
44
-
45
- tokens = entry.split(/\s+/)
46
- # Fail closed (G3 triage, Priority-1 site): a classifier crash
47
- # must not silently skip the broad-token RCE check — treat the
48
- # token as broad and let the operator pass a narrow prefix.
49
- broad = begin
50
- base = Rubik::Security::Classifier.classify(tokens.first)
51
- base.classification == :dangerous && tokens.size == 1
52
- rescue StandardError => e
53
- Rubik::Log.warn(:security, "classifier failed for '#{tokens.first}' (#{e.class}: #{e.message}); treating as broad")
54
- true
55
- end
56
-
57
- # Fail-closed against unbounded execution: a broad, single-token
58
- # dangerous entry (e.g. "node", "python", "bash") grants
59
- # command execution for EVERY invocation of that binary, so
60
- # `node` → `node -e 'malicious()'` RCE. Such broad tokens are
61
- # blocked with or without --force — the operator should use a
62
- # narrow prefix (e.g. "node .gitnexus/run.cjs").
63
- if broad
64
- ctx.out.puts "Blocked: '#{entry}' is a broad, single-token dangerous command. Even with --force it grants " \
65
- "unbounded command execution (e.g. `#{tokens.first} -e 'malicious(...)'` RCE). " \
66
- "Use a narrow prefix instead (e.g. \"#{tokens.first} <script>\")."
67
- return
68
- end
69
-
70
- # The catastrophic destructive-pattern fallback always runs:
71
- # a dangerous multi-token entry may never add `rm -rf /`-style
72
- # chains to the allowlist, with or without --force.
73
- catastrophic = false
74
- begin
75
- ctx.guard.verify_command_fallback_regex!(entry)
76
- rescue Rubik::Security::BlockedWriteError => e
77
- ctx.out.puts "Blocked: '#{entry}' matches a catastrophic destructive pattern and cannot be allowlisted: #{e.message}"
78
- catastrophic = true
79
- end
80
- return if catastrophic
81
-
82
- current = Array(ctx.config.fetch("security.command_allowlist", [])).dup
83
- if current.include?(entry)
84
- ctx.out.puts "[Rubik::AllowCmd] '#{entry}' is already allowlisted."
85
- return
86
- end
87
- current << entry
88
- res = ctx.config.set("security.command_allowlist", current, persist: scope)
89
- ctx.guard&.log_audit("allowlist_add", command: entry, scope: scope)
90
- next if Base.report_persist_failure(ctx, res, scope)
91
-
92
- ctx.out.puts "[Rubik::AllowCmd] Added command prefix: #{entry} [#{Base.scope_label(scope)}]."
93
- end
94
-
95
37
  case sub
96
38
  when "list", "status"
97
- entries = Array(ctx.config.fetch("security.command_allowlist", []))
98
- ctx.out.puts "=== Allowed Command Prefixes (operator allowlist) ==="
99
- if entries.empty?
100
- ctx.out.puts " (No command prefixes allowlisted — unknown/dangerous commands stay blocked)"
101
- else
102
- entries.each_with_index { |e, idx| ctx.out.puts " • [#{idx + 1}] #{e}" }
103
- end
39
+ handle_allow_cmd_list(ctx)
104
40
  when "add"
105
- handle_add.call(clean_args[1..])
41
+ handle_allow_cmd_add(ctx, clean_args[1..], scope)
106
42
  when "remove", "rm", "delete"
107
- entry = clean_args[1..].join(" ").strip
108
- if entry.empty?
109
- ctx.out.puts "Usage: !allow-cmd remove \"<command prefix>\" [--project|--global]"
110
- next
111
- end
112
-
113
- current = Array(ctx.config.fetch("security.command_allowlist", [])).dup
114
- unless current.include?(entry)
115
- ctx.out.puts "[Rubik::AllowCmd] '#{entry}' is not in the allowlist."
116
- next
117
- end
118
- current.delete(entry)
119
- res = ctx.config.set("security.command_allowlist", current, persist: scope)
120
- ctx.guard&.log_audit("allowlist_remove", command: entry, scope: scope)
121
- next if Base.report_persist_failure(ctx, res, scope)
122
-
123
- ctx.out.puts "[Rubik::AllowCmd] Removed command prefix: #{entry} [#{Base.scope_label(scope)}]."
43
+ handle_allow_cmd_remove(ctx, clean_args, scope)
124
44
  when "help", "-h", "--help"
125
45
  ctx.out.puts USAGE_ALLOW_CMD
126
46
  else
127
- handle_add.call(clean_args)
47
+ handle_allow_cmd_add(ctx, clean_args, scope)
128
48
  end
129
49
  end
130
50
  end
131
51
 
52
+ private_class_method def self.handle_allow_cmd_list(ctx)
53
+ entries = Array(ctx.config.fetch("security.command_allowlist", []))
54
+ ctx.out.puts "=== Allowed Command Prefixes (operator allowlist) ==="
55
+ if entries.empty?
56
+ ctx.out.puts " (No command prefixes allowlisted — unknown/dangerous commands stay blocked)"
57
+ else
58
+ entries.each_with_index { |e, idx| ctx.out.puts " • [#{idx + 1}] #{e}" }
59
+ end
60
+ end
61
+
62
+ private_class_method def self.handle_allow_cmd_add(ctx, tokens_to_add, scope)
63
+ entry_tokens = tokens_to_add.reject { |a| a == "--force" }
64
+ entry = entry_tokens.join(" ").strip
65
+ if entry.empty?
66
+ ctx.out.puts 'Usage: !allow-cmd add "<command prefix>" [--project|--global] [--force]'
67
+ return
68
+ end
69
+
70
+ tokens = entry.split(/\s+/)
71
+ # Fail closed (G3 triage, Priority-1 site): a classifier crash
72
+ # must not silently skip the broad-token RCE check — treat the
73
+ # token as broad and let the operator pass a narrow prefix.
74
+ broad = begin
75
+ base = Rubik::Security::Classifier.classify(tokens.first)
76
+ base.classification == :dangerous && tokens.size == 1
77
+ rescue StandardError => e
78
+ Rubik::Log.warn(:security, "classifier failed for '#{tokens.first}' (#{e.class}: #{e.message}); treating as broad")
79
+ true
80
+ end
81
+
82
+ # Fail-closed against unbounded execution: a broad, single-token
83
+ # dangerous entry (e.g. "node", "python", "bash") grants
84
+ # command execution for EVERY invocation of that binary, so
85
+ # `node` → `node -e 'malicious()'` RCE. Such broad tokens are
86
+ # blocked with or without --force — the operator should use a
87
+ # narrow prefix (e.g. "node .gitnexus/run.cjs").
88
+ if broad
89
+ ctx.out.puts "Blocked: '#{entry}' is a broad, single-token dangerous command. Even with --force it grants " \
90
+ "unbounded command execution (e.g. `#{tokens.first} -e 'malicious(...)'` RCE). " \
91
+ "Use a narrow prefix instead (e.g. \"#{tokens.first} <script>\")."
92
+ return
93
+ end
94
+
95
+ # The catastrophic destructive-pattern fallback always runs:
96
+ # a dangerous multi-token entry may never add `rm -rf /`-style
97
+ # chains to the allowlist, with or without --force.
98
+ catastrophic = false
99
+ begin
100
+ ctx.guard.verify_command_fallback_regex!(entry)
101
+ rescue Rubik::Security::BlockedWriteError => e
102
+ ctx.out.puts "Blocked: '#{entry}' matches a catastrophic destructive pattern and cannot be allowlisted: #{e.message}"
103
+ catastrophic = true
104
+ end
105
+ return if catastrophic
106
+
107
+ current = Array(ctx.config.fetch("security.command_allowlist", [])).dup
108
+ if current.include?(entry)
109
+ ctx.out.puts "[Rubik::AllowCmd] '#{entry}' is already allowlisted."
110
+ return
111
+ end
112
+ current << entry
113
+ res = ctx.config.set("security.command_allowlist", current, persist: scope)
114
+ ctx.guard&.log_audit("allowlist_add", command: entry, scope: scope)
115
+ return if Base.report_persist_failure(ctx, res, scope)
116
+
117
+ ctx.out.puts "[Rubik::AllowCmd] Added command prefix: #{entry} [#{Base.scope_label(scope)}]."
118
+ end
119
+
120
+ private_class_method def self.handle_allow_cmd_remove(ctx, clean_args, scope)
121
+ entry = clean_args[1..].join(" ").strip
122
+ if entry.empty?
123
+ ctx.out.puts "Usage: !allow-cmd remove \"<command prefix>\" [--project|--global]"
124
+ return
125
+ end
126
+
127
+ current = Array(ctx.config.fetch("security.command_allowlist", [])).dup
128
+ unless current.include?(entry)
129
+ ctx.out.puts "[Rubik::AllowCmd] '#{entry}' is not in the allowlist."
130
+ return
131
+ end
132
+ current.delete(entry)
133
+ res = ctx.config.set("security.command_allowlist", current, persist: scope)
134
+ ctx.guard&.log_audit("allowlist_remove", command: entry, scope: scope)
135
+ return if Base.report_persist_failure(ctx, res, scope)
136
+
137
+ ctx.out.puts "[Rubik::AllowCmd] Removed command prefix: #{entry} [#{Base.scope_label(scope)}]."
138
+ end
139
+
132
140
  private_class_method def self.register_workspace_command(shell)
133
141
  workspace_handler = lambda do |args, ctx|
134
142
  scope, clean_args = Base.parse_scope(args)
@@ -263,80 +271,16 @@ module Rubik
263
271
 
264
272
  case sub
265
273
  when "list", "status"
266
- allowed = ctx.buffer_manager&.allowed_external_dirs || []
267
- rw_set = ctx.buffer_manager&.rw_allowed_dirs || Set.new
268
- glob_rw = ctx.buffer_manager&.allow_external_writes || false
269
-
270
- ctx.out.puts "=== Allowed External Directories (Zero-AST / Zero-Scanner) ==="
271
- if allowed.empty?
272
- ctx.out.puts " (No allowed external directories configured)"
273
- else
274
- allowed.each_with_index do |d, idx|
275
- mode_str = glob_rw || rw_set.include?(d) ? "Read-Write" : "Read-Only"
276
- ctx.out.puts " • [ALLOWED #{idx + 1}] #{d} (#{mode_str})"
277
- end
278
- end
274
+ handle_allow_list(ctx)
279
275
  when "remove", "rm", "delete"
280
- target_path = clean_args[1]
281
- if target_path.nil? || target_path.empty?
282
- ctx.out.puts "Usage: !allow remove <path> [--project|--global]"
283
- else
284
- expanded = ctx.buffer_manager&.remove_allowed_dir(target_path)
285
- ctx.sandbox_runner&.remove_allowed_dir(target_path)
286
-
287
- # Persist to config
288
- current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
289
- current_dirs.reject! { |d| File.expand_path(d, effective_root) == expanded || d == target_path }
290
- res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
291
- next if Base.report_persist_failure(ctx, res, scope)
292
-
293
- ctx.out.puts "[Rubik::Allow] Removed allowed external directory: #{expanded} [#{Base.scope_label(scope)}]."
294
- end
276
+ handle_allow_remove(clean_args[1], ctx, scope, effective_root)
295
277
  when "add"
296
- target_path = clean_args[1]
297
- if target_path.nil? || target_path.empty?
298
- ctx.out.puts "Usage: !allow add <path> [--rw|--ro] [--project|--global]"
299
- else
300
- begin
301
- ctx.guard.verify_root_safety!(target_path)
302
- rescue Rubik::Security::BlockedWriteError => e
303
- ctx.out.puts "Security Error: #{e.message}"
304
- next
305
- end
306
-
307
- expanded = ctx.buffer_manager.add_allowed_dir(target_path, rw: mode_rw)
308
- ctx.sandbox_runner&.add_allowed_dir(target_path)
309
-
310
- # Persist to config (Zero-AST / Zero-Scanner: No IndexService or AST warm)
311
- current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
312
- current_dirs << target_path unless current_dirs.include?(target_path)
313
- res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
314
- next if Base.report_persist_failure(ctx, res, scope)
315
-
316
- mode_str = mode_rw ? "Read-Write" : "Read-Only"
317
- ctx.out.puts "[Rubik::Allow] Added allowed external directory: #{expanded} (Mode: #{mode_str}) [#{Base.scope_label(scope)}]."
318
- end
278
+ handle_allow_add(clean_args[1], ctx, scope, mode_rw)
319
279
  else
320
280
  # First argument is target path directly
321
281
  target_path = clean_args.first
322
282
  if target_path && !target_path.empty?
323
- begin
324
- ctx.guard.verify_root_safety!(target_path)
325
- rescue Rubik::Security::BlockedWriteError => e
326
- ctx.out.puts "Security Error: #{e.message}"
327
- next
328
- end
329
-
330
- expanded = ctx.buffer_manager.add_allowed_dir(target_path, rw: mode_rw)
331
- ctx.sandbox_runner&.add_allowed_dir(target_path)
332
-
333
- current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
334
- current_dirs << target_path unless current_dirs.include?(target_path)
335
- res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
336
- next if Base.report_persist_failure(ctx, res, scope)
337
-
338
- mode_str = mode_rw ? "Read-Write" : "Read-Only"
339
- ctx.out.puts "[Rubik::Allow] Added allowed external directory: #{expanded} (Mode: #{mode_str}) [#{Base.scope_label(scope)}]."
283
+ handle_allow_add(target_path, ctx, scope, mode_rw)
340
284
  else
341
285
  cmd_name = invoked_as == :allow ? "allow" : "allow-dir"
342
286
  ctx.out.puts format(USAGE_ALLOW, cmd: cmd_name)
@@ -359,6 +303,66 @@ module Rubik
359
303
  usage: format(USAGE_ALLOW, cmd: "allow"), &deprecated_allow_wrapper)
360
304
  end
361
305
 
306
+ private_class_method def self.handle_allow_list(ctx)
307
+ allowed = ctx.buffer_manager&.allowed_external_dirs || []
308
+ rw_set = ctx.buffer_manager&.rw_allowed_dirs || Set.new
309
+ glob_rw = ctx.buffer_manager&.allow_external_writes || false
310
+
311
+ ctx.out.puts "=== Allowed External Directories (Zero-AST / Zero-Scanner) ==="
312
+ if allowed.empty?
313
+ ctx.out.puts " (No allowed external directories configured)"
314
+ else
315
+ allowed.each_with_index do |d, idx|
316
+ mode_str = glob_rw || rw_set.include?(d) ? "Read-Write" : "Read-Only"
317
+ ctx.out.puts " • [ALLOWED #{idx + 1}] #{d} (#{mode_str})"
318
+ end
319
+ end
320
+ end
321
+
322
+ private_class_method def self.handle_allow_remove(target_path, ctx, scope, effective_root)
323
+ if target_path.nil? || target_path.empty?
324
+ ctx.out.puts "Usage: !allow remove <path> [--project|--global]"
325
+ return
326
+ end
327
+
328
+ expanded = ctx.buffer_manager&.remove_allowed_dir(target_path)
329
+ ctx.sandbox_runner&.remove_allowed_dir(target_path)
330
+
331
+ # Persist to config
332
+ current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
333
+ current_dirs.reject! { |d| File.expand_path(d, effective_root) == expanded || d == target_path }
334
+ res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
335
+ return if Base.report_persist_failure(ctx, res, scope)
336
+
337
+ ctx.out.puts "[Rubik::Allow] Removed allowed external directory: #{expanded} [#{Base.scope_label(scope)}]."
338
+ end
339
+
340
+ private_class_method def self.handle_allow_add(target_path, ctx, scope, mode_rw)
341
+ if target_path.nil? || target_path.empty?
342
+ ctx.out.puts "Usage: !allow add <path> [--rw|--ro] [--project|--global]"
343
+ return
344
+ end
345
+
346
+ begin
347
+ ctx.guard.verify_root_safety!(target_path)
348
+ rescue Rubik::Security::BlockedWriteError => e
349
+ ctx.out.puts "Security Error: #{e.message}"
350
+ return
351
+ end
352
+
353
+ expanded = ctx.buffer_manager.add_allowed_dir(target_path, rw: mode_rw)
354
+ ctx.sandbox_runner&.add_allowed_dir(target_path)
355
+
356
+ # Persist to config (Zero-AST / Zero-Scanner: No IndexService or AST warm)
357
+ current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
358
+ current_dirs << target_path unless current_dirs.include?(target_path)
359
+ res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
360
+ return if Base.report_persist_failure(ctx, res, scope)
361
+
362
+ mode_str = mode_rw ? "Read-Write" : "Read-Only"
363
+ ctx.out.puts "[Rubik::Allow] Added allowed external directory: #{expanded} (Mode: #{mode_str}) [#{Base.scope_label(scope)}]."
364
+ end
365
+
362
366
  private_class_method def self.register_config_command(shell)
363
367
  shell.register_command("config", description: "Get, set, or view runtime configuration (set|get|list|path)",
364
368
  usage: USAGE_CONFIG, audience: :both) do |args, ctx|
@@ -48,7 +48,10 @@ module Rubik
48
48
  end
49
49
 
50
50
  def manifest_path(dir)
51
- File.join(dir, ".manifest.json")
51
+ ensure_valid_overlay_dir!(dir)
52
+ path = File.join(dir, ".manifest.json")
53
+ ensure_contained_path!(path, dir)
54
+ path
52
55
  end
53
56
 
54
57
  def load_manifest(dir)
@@ -56,7 +59,7 @@ module Rubik
56
59
  return {} unless File.exist?(path)
57
60
 
58
61
  JSON.parse(File.read(path))
59
- rescue JSON::ParserError
62
+ rescue JSON::ParserError, ArgumentError
60
63
  {}
61
64
  end
62
65
 
@@ -73,6 +76,7 @@ module Rubik
73
76
  end
74
77
 
75
78
  def record_hash(dir, filename, scope)
79
+ ensure_valid_overlay_dir!(dir)
76
80
  if manifest_state(dir) == :corrupt
77
81
  # Bozuk manifesti sessizce "heal" etmek (yalnızca yeni kaydı yazmak) diğer
78
82
  # kayıtlı overlay'leri tahliye eder; bunun yerine kaydı reddet.
@@ -80,6 +84,7 @@ module Rubik
80
84
  end
81
85
 
82
86
  path = File.join(dir, filename)
87
+ ensure_contained_path!(path, dir)
83
88
  hash = Digest::SHA256.hexdigest(normalize_content(File.binread(path)))
84
89
  manifest = load_manifest(dir)
85
90
  manifest[filename] = {
@@ -93,6 +98,11 @@ module Rubik
93
98
  def verify_integrity(file_path, dir, bootstrap_eligible: false)
94
99
  return :trusted if trust_mode?
95
100
 
101
+ unless safe_path_under?(file_path, dir)
102
+ warn "[SECURITY] Path traversal attempt detected for overlay '#{file_path}'. Skipping."
103
+ return :rejected
104
+ end
105
+
96
106
  basename = File.basename(file_path)
97
107
  manifest = load_manifest(dir)
98
108
  current_content = File.exist?(file_path) ? File.binread(file_path) : ""
@@ -140,6 +150,8 @@ module Rubik
140
150
 
141
151
  filename = "#{clean_name.gsub(':', '_')}.rb"
142
152
  file_path = File.join(target_dir, filename)
153
+ ensure_contained_path!(file_path, target_dir)
154
+
143
155
  header = <<~RUBY
144
156
  # frozen_string_literal: true
145
157
  #{COMMAND_SENTINEL} !#{clean_name}
@@ -164,6 +176,7 @@ module Rubik
164
176
  target_dir = scope.to_sym == :global ? global_commands_dir : project_commands_dir
165
177
  filename = "#{clean_name.gsub(':', '_')}.rb"
166
178
  file_path = File.join(target_dir, filename)
179
+ ensure_contained_path!(file_path, target_dir)
167
180
 
168
181
  raise "cannot delete '#{filename}': .manifest.json in #{target_dir} is corrupt; delete it and re-record overlays" if manifest_state(target_dir) == :corrupt
169
182
 
@@ -190,8 +203,10 @@ module Rubik
190
203
 
191
204
  file_path = cmd_meta[:path]
192
205
  cmd_name = cmd_meta[:name]
206
+ target_dir = cmd_meta[:scope].to_sym == :global ? global_commands_dir : project_commands_dir
193
207
 
194
208
  begin
209
+ ensure_contained_path!(file_path, target_dir)
195
210
  code = File.read(file_path)
196
211
  eval_command(shell, cmd_name, code, cmd_meta[:scope])
197
212
  loaded << cmd_name
@@ -249,6 +264,8 @@ module Rubik
249
264
 
250
265
  filename = "#{clean_name}.rb"
251
266
  file_path = File.join(target_dir, filename)
267
+ ensure_contained_path!(file_path, target_dir)
268
+
252
269
  schema_json = params_schema ? JSON.dump(params_schema) : "{}"
253
270
  header = <<~RUBY
254
271
  # frozen_string_literal: true
@@ -278,6 +295,7 @@ module Rubik
278
295
  target_dir = scope.to_sym == :global ? global_tools_dir : project_tools_dir
279
296
  filename = "#{clean_name}.rb"
280
297
  file_path = File.join(target_dir, filename)
298
+ ensure_contained_path!(file_path, target_dir)
281
299
 
282
300
  raise "cannot delete '#{filename}': .manifest.json in #{target_dir} is corrupt; delete it and re-record overlays" if manifest_state(target_dir) == :corrupt
283
301
 
@@ -301,6 +319,10 @@ module Rubik
301
319
  list_tools.each do |t_meta|
302
320
  next if t_meta[:integrity] == :rejected
303
321
 
322
+ file_path = t_meta[:path]
323
+ target_dir = t_meta[:scope].to_sym == :global ? global_tools_dir : project_tools_dir
324
+ ensure_contained_path!(file_path, target_dir)
325
+
304
326
  code = File.read(t_meta[:path])
305
327
  tool_inst = eval_tool(t_meta[:name], code, t_meta[:scope])
306
328
  if tool_inst
@@ -357,6 +379,41 @@ module Rubik
357
379
 
358
380
  private
359
381
 
382
+ def ensure_valid_overlay_dir!(dir)
383
+ allowed = [
384
+ [project_commands_dir, @root_dir],
385
+ [global_commands_dir, @home_dir],
386
+ [project_tools_dir, @root_dir],
387
+ [global_tools_dir, @home_dir]
388
+ ]
389
+ valid = allowed.any? do |allowed_dir, trusted_root|
390
+ Platform.path_equal?(dir, allowed_dir) && Platform.resolved_path_under?(dir, trusted_root)
391
+ end
392
+ return if valid
393
+
394
+ raise ArgumentError, "Invalid overlay directory: #{dir}"
395
+ end
396
+
397
+ def ensure_contained_path!(path, parent_dir)
398
+ raise ArgumentError, "Path traversal attempt detected: #{path}" unless safe_path_under?(path, parent_dir)
399
+ end
400
+
401
+ def safe_path_under?(path, parent_dir)
402
+ return false if path.nil? || parent_dir.nil?
403
+
404
+ ensure_valid_overlay_dir!(parent_dir)
405
+
406
+ # Lexical containment check first
407
+ return false unless Platform.path_under?(path, parent_dir)
408
+
409
+ # If path exists or parent exists, also check resolved path containment
410
+ return Platform.resolved_path_under?(path, parent_dir) if File.exist?(path) || File.exist?(parent_dir)
411
+
412
+ true
413
+ rescue StandardError
414
+ false
415
+ end
416
+
360
417
  # Bootstrap kabul taramasında kullanılacak guard: kwarg'la geçilen örnek
361
418
  # (üretimde service catalog'un tek operatör-policy guard'ı) ya da lazy
362
419
  # default. Lazy çözüm bilinçli: overlay_storage.rb standalone require
@@ -14,7 +14,7 @@ module Rubik
14
14
  DEFAULT_IMAGE = "rubik-sandbox:latest"
15
15
 
16
16
  def available?
17
- !Rubik::Platform.which("docker").nil? && system("docker ps > #{Rubik::Platform.dev_null} 2>&1")
17
+ !Rubik::Platform.which("docker").nil? && system("docker", "ps", out: File::NULL, err: File::NULL)
18
18
  end
19
19
 
20
20
  def run(cmd, timeout: 15, chdir: Dir.pwd, allow_network: false, memory_limit_mb: 512, cpus: "1.0")
@@ -48,7 +48,7 @@ module Rubik
48
48
  error_message: "Docker sandbox error"
49
49
  )
50
50
  ensure
51
- system("docker rm -f #{container_name} > #{Rubik::Platform.dev_null} 2>&1")
51
+ system("docker", "rm", "-f", container_name, out: File::NULL, err: File::NULL)
52
52
  end
53
53
 
54
54
  {
@@ -18,9 +18,8 @@ module Rubik
18
18
  def replay_file(session_file_path)
19
19
  return false unless File.exist?(session_file_path)
20
20
 
21
- lines = File.readlines(session_file_path)
22
21
  @shell&.instance_variable_set(:@replaying, true)
23
- lines.each do |line|
22
+ File.foreach(session_file_path) do |line|
24
23
  next if line.strip.empty?
25
24
 
26
25
  event = begin
@@ -499,10 +499,11 @@ module Rubik
499
499
  # (elle düzenleme: "42", ["x"]) tüm tipi zehirlemesin.
500
500
  skipped = 0
501
501
  records = File.foreach(path, encoding: "UTF-8").filter_map do |line|
502
- next if line.strip.empty?
502
+ line.strip!
503
+ next if line.empty?
503
504
 
504
505
  parsed = begin
505
- JSON.parse(line.scrub)
506
+ JSON.parse(line.valid_encoding? ? line : line.scrub)
506
507
  rescue JSON::ParserError
507
508
  nil
508
509
  end
@@ -153,8 +153,13 @@ module Rubik
153
153
  pref = Array.new(f_lines.size + 1, 0)
154
154
  f_lines.each_with_index { |l, idx| pref[idx + 1] = pref[idx] + (target_set.include?(l) ? 1 : 0) }
155
155
 
156
- (0...(f_lines.size)).each do |i|
157
- window_len = [k, f_lines.size - i].min
156
+ # PERF: while loop with direct index increments avoids Enumerator/Range allocations per call (~17-20% faster, ~12% fewer allocations).
157
+ i = 0
158
+ f_lines_size = f_lines.size
159
+ has_diff_lcs = defined?(Diff::LCS) && Diff::LCS.respond_to?(:LCS)
160
+ while i < f_lines_size
161
+ rem = f_lines_size - i
162
+ window_len = [k, rem].min
158
163
  total = [k, window_len].max
159
164
 
160
165
  overlap_count = pref[i + window_len] - pref[i]
@@ -168,27 +173,38 @@ module Rubik
168
173
  end
169
174
  else
170
175
  # PERF: Upper-bound check — if maximum possible overlap cannot beat best_score, skip LCS
171
- next if (overlap_count.to_f / total) <= best_score
176
+ if (overlap_count.to_f / total) <= best_score
177
+ i += 1
178
+ next
179
+ end
172
180
 
173
181
  # PERF: Array#[start, len] slice avoids Range object allocation inside hot loop
174
182
  window = f_lines[i, window_len]
175
183
 
176
184
  matches =
177
- if defined?(Diff::LCS) && Diff::LCS.respond_to?(:LCS)
185
+ if has_diff_lcs
178
186
  Diff::LCS.LCS(t_lines, window).size
179
187
  else
180
188
  # Fallback line matching
181
- (0...[t_lines.size, window.size].min).count { |idx| t_lines[idx] == window[idx] }
189
+ m = 0
190
+ idx = 0
191
+ limit = [t_lines.size, window.size].min
192
+ while idx < limit
193
+ m += 1 if t_lines[idx] == window[idx]
194
+ idx += 1
195
+ end
196
+ m
182
197
  end
183
198
  score = total.positive? ? (matches.to_f / total) : 0.0
184
199
  end
185
200
 
186
- next unless score > best_score
187
-
188
- best_score = score
189
- best_range = (i + 1)..(i + window_len)
190
- best_i = i
191
- best_window_len = window_len
201
+ if score > best_score
202
+ best_score = score
203
+ best_range = (i + 1)..(i + window_len)
204
+ best_i = i
205
+ best_window_len = window_len
206
+ end
207
+ i += 1
192
208
  end
193
209
 
194
210
  return nil if best_score < 0.25 || best_i.nil?
data/lib/rubik/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubik
4
- VERSION = "0.3.3"
4
+ VERSION = "0.3.4"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubik
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.3
4
+ version: 0.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Özgür Ulukır