rubik 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -10
- data/lib/rubik/concurrency/event_loop.rb +63 -4
- data/lib/rubik/concurrency/fiber_timeout.rb +36 -0
- data/lib/rubik/concurrency/scheduler.rb +15 -0
- data/lib/rubik/concurrency/timers.rb +18 -0
- data/lib/rubik/concurrency.rb +7 -4
- data/lib/rubik/lsp/client.rb +7 -7
- data/lib/rubik/platform/path.rb +34 -8
- data/lib/rubik/security/guard.rb +21 -0
- data/lib/rubik/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 13f2d00bb2c72a92d48741df2cb3c3e84fc970a9ce3e4e844722afa58ef59599
|
|
4
|
+
data.tar.gz: f3314db467118bbd56aa79625f954f5c9e76611c56b5eb5e467d442fa440aa63
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a5c29f5af7119d6b9189e37d39390c6dd2463caa1fd870e49022b92a0ed72481b227616000a84a2e201f26c7a65009f7fcb05ac6591db6ca1b9ecae3f81bc6d4
|
|
7
|
+
data.tar.gz: 3ab1188c4607f7dbcbe4011111723f530555052497154eaea6c8d47b09dc927bfcfa43b50b88869f2dc8bc1e402c7589a569d59ad584d29b991bfff765292d08
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,44 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
8
8
|
|
|
9
9
|
## Unreleased
|
|
10
10
|
|
|
11
|
+
Nothing yet.
|
|
12
|
+
|
|
13
|
+
## [0.1.1] - 2026-10-11
|
|
14
|
+
|
|
15
|
+
The Linux surface release: CI now proves Linux on Ruby 3.4 and 4.0
|
|
16
|
+
(`linux-test` is a required check), and the host seams that surface
|
|
17
|
+
exposed are fixed. Ships the structured goal ledger, the default-on
|
|
18
|
+
context pruner, and the vakanüvis recall/write hardening below.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **Fiber Scheduler: Net timeouts no longer fire watchdogs into
|
|
23
|
+
unrelated frames** (#102): Net::HTTP implemented its open/read
|
|
24
|
+
deadlines with the Timeout module — an async `Thread#raise` into the
|
|
25
|
+
originating thread. Under the scheduler that thread hops between
|
|
26
|
+
parked fibers, so an expired deadline landed in whichever frame was
|
|
27
|
+
running (observed on Linux killing the test reporter's stdout write
|
|
28
|
+
and `safe_select`'s `IO.select` itself). Deadlines are now
|
|
29
|
+
fiber-native: EventLoop timers drive them, and the timeout never
|
|
30
|
+
leaves the owning fiber.
|
|
31
|
+
- **LSP diagnostics keys normalize by path shape, not host OS** (#103):
|
|
32
|
+
a windows-drive root plus `file:///C:/...` uris and backslash lookups
|
|
33
|
+
resolved differently on a linux host; drive semantics now follow the
|
|
34
|
+
path's own shape.
|
|
35
|
+
- **Allowlist `.exe` alias matches lexically** (#103): the windows
|
|
36
|
+
executable-extension rule engaged through a second host seam the
|
|
37
|
+
platform stub did not cover; the alias is decided inside the matcher.
|
|
38
|
+
- **Task manager fixtures are shell-safe and poll for readiness**
|
|
39
|
+
(#103): fixed sleeps raced linux process startup, so a kill arrived
|
|
40
|
+
after the task had already FAILED.
|
|
41
|
+
- **REPL wake-loop**: subagent notices stranded at turn end re-armed the
|
|
42
|
+
reactive wake (synthetic Enter), so a steady subagent stream chained
|
|
43
|
+
autonomous maestro turns forever — the prompt never returned and the cycle
|
|
44
|
+
survived Ctrl+C, because the interrupted turn's own ensure block re-armed
|
|
45
|
+
the wake. Settlement invariant: an autonomous notice turn never spawns
|
|
46
|
+
another one, and an interrupted turn settles the wake debt; the
|
|
47
|
+
model-channel no-loss delivery is unchanged.
|
|
48
|
+
|
|
11
49
|
### Added
|
|
12
50
|
|
|
13
51
|
- **Vakanüvis recall + write hardening** (issues #99/#100): `storage put`
|
|
@@ -91,16 +129,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
91
129
|
subagent reported red?"); failed instances still get a body (task + state
|
|
92
130
|
line) and the upsert/dedupe/canonical-link contract is unchanged.
|
|
93
131
|
|
|
94
|
-
### Fixed
|
|
95
|
-
|
|
96
|
-
- **REPL wake-loop**: subagent notices stranded at turn end re-armed the
|
|
97
|
-
reactive wake (synthetic Enter), so a steady subagent stream chained
|
|
98
|
-
autonomous maestro turns forever — the prompt never returned and the cycle
|
|
99
|
-
survived Ctrl+C, because the interrupted turn's own ensure block re-armed
|
|
100
|
-
the wake. Settlement invariant: an autonomous notice turn never spawns
|
|
101
|
-
another one, and an interrupted turn settles the wake debt; the
|
|
102
|
-
model-channel no-loss delivery is unchanged.
|
|
103
|
-
|
|
104
132
|
## [0.1.0] - 2026-10-09
|
|
105
133
|
|
|
106
134
|
First rubygems.org release of the `rubik` gem. Linux and macOS install out of
|
|
@@ -28,8 +28,21 @@ module Rubik
|
|
|
28
28
|
# Thread#join/block), io-waiting, or timer-pending. Finished fibers leave
|
|
29
29
|
# no registration, so "is anything waiting?" answers "are we done?".
|
|
30
30
|
class EventLoop
|
|
31
|
-
|
|
32
|
-
WRITABLE
|
|
31
|
+
# The VM's io_wait hook passes and expects the scheduler-protocol wait
|
|
32
|
+
# mask — the same domain as ::IO::READABLE / ::IO::WRITABLE (1 and 4;
|
|
33
|
+
# 2 is IO::PRIORITY). A local 1/2 pair silently drops WRITABLE
|
|
34
|
+
# interests (5.anybits?(2) is false): connects park forever until a
|
|
35
|
+
# deadline fires — the mis-aimed-watchdog crash of #102 never even
|
|
36
|
+
# needed the watchdog. Aliased to the real constants so a protocol
|
|
37
|
+
# drift surfaces as a NameError, not a hang.
|
|
38
|
+
READABLE = ::IO::READABLE
|
|
39
|
+
WRITABLE = ::IO::WRITABLE
|
|
40
|
+
|
|
41
|
+
# Timer generation slot for deadline registrations that must survive
|
|
42
|
+
# re-parks (Timeout.timeout spans many io waits within ONE timed
|
|
43
|
+
# operation). Sticky entries never go stale — they are cancelled
|
|
44
|
+
# explicitly by the owning fiber's ensure.
|
|
45
|
+
STICKY = :sticky
|
|
33
46
|
|
|
34
47
|
attr_reader :spawn_errors
|
|
35
48
|
|
|
@@ -41,7 +54,11 @@ module Rubik
|
|
|
41
54
|
# Stale timers (fiber re-parked or resumed since registration) must
|
|
42
55
|
# not gate draining — @gen is bumped on every resume, so any of this
|
|
43
56
|
# fiber's outstanding registrations becomes inert at that moment.
|
|
44
|
-
@timers = Timers.new
|
|
57
|
+
@timers = Timers.new do |fiber, gen|
|
|
58
|
+
next false if gen == STICKY
|
|
59
|
+
|
|
60
|
+
@gen[fiber] != gen
|
|
61
|
+
end
|
|
45
62
|
@io_waiters = {} # io => { read: {fiber=>gen}, write: {fiber=>gen} }
|
|
46
63
|
@gen = ::Hash.new(0) # fiber => park generation (loop thread only)
|
|
47
64
|
@live = {} # fiber => true while a spawned fiber runs
|
|
@@ -212,6 +229,34 @@ module Rubik
|
|
|
212
229
|
::Addrinfo.getaddrinfo(hostname, nil).map(&:ip_address).uniq
|
|
213
230
|
end
|
|
214
231
|
|
|
232
|
+
# True while this thread is inside run/run_until_drained (a boundary
|
|
233
|
+
# is draining). Timeout deadlines are fiber-native only in this
|
|
234
|
+
# window — outside it no loop consumes the timers, so the patch falls
|
|
235
|
+
# back to the stock Timeout watchdog.
|
|
236
|
+
def draining?
|
|
237
|
+
!@closed && !@loop_thread.nil?
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Fiber-native Timeout.timeout body (see concurrency/fiber_timeout.rb):
|
|
241
|
+
# runs the block inline and delivers the deadline exception through
|
|
242
|
+
# the interrupt path at the fiber's current park. The :sticky
|
|
243
|
+
# generation means re-parks during the timed operation never stale
|
|
244
|
+
# the deadline (a Net open spans many io waits); the ensure cancels
|
|
245
|
+
# the timer and retracts an already-fired, still-queued delivery, so
|
|
246
|
+
# a deadline that loses the race against block completion can never
|
|
247
|
+
# leak into a later operation.
|
|
248
|
+
def fiber_timeout(sec, exception_class, message)
|
|
249
|
+
fiber = ::Fiber.current
|
|
250
|
+
error = (exception_class || ::Timeout::Error).new(message || "execution expired")
|
|
251
|
+
timer = @timers.add(monotonic + sec, fiber, error, STICKY)
|
|
252
|
+
begin
|
|
253
|
+
yield
|
|
254
|
+
ensure
|
|
255
|
+
@timers.cancel(timer)
|
|
256
|
+
discard_queued(fiber, error)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
215
260
|
# ---- Reactor -------------------------------------------------------------
|
|
216
261
|
|
|
217
262
|
# Runs the boundary body as a fiber and drains everything it spawned.
|
|
@@ -359,7 +404,7 @@ module Rubik
|
|
|
359
404
|
|
|
360
405
|
def fire_due_timers
|
|
361
406
|
@timers.fire_due!(monotonic) do |fiber, value, gen|
|
|
362
|
-
next if @gen[fiber] != gen # stale: fiber moved on after registering
|
|
407
|
+
next if gen != STICKY && @gen[fiber] != gen # stale: fiber moved on after registering
|
|
363
408
|
|
|
364
409
|
drop_io_interest(fiber)
|
|
365
410
|
push(fiber, value: value)
|
|
@@ -427,6 +472,20 @@ module Rubik
|
|
|
427
472
|
nil
|
|
428
473
|
end
|
|
429
474
|
|
|
475
|
+
# Retract a queued resume: a deadline that fired while its fiber was
|
|
476
|
+
# running (mid-operation) sits in @runnable until the next pop —
|
|
477
|
+
# without retraction, the block's completion would leave the
|
|
478
|
+
# exception aimed at the fiber's NEXT park (the watchdog-leak shape,
|
|
479
|
+
# in miniature). Same-thread ordering makes this airtight: the entry
|
|
480
|
+
# can only be consumed by a resume that delivers it into the timed
|
|
481
|
+
# block, and then the ensure's cancel finds nothing to retract.
|
|
482
|
+
def discard_queued(fiber, value)
|
|
483
|
+
@mutex.synchronize do
|
|
484
|
+
@runnable.reject! { |(queued_fiber, queued_value)| queued_fiber.equal?(fiber) && queued_value.equal?(value) }
|
|
485
|
+
end
|
|
486
|
+
nil
|
|
487
|
+
end
|
|
488
|
+
|
|
430
489
|
def kick
|
|
431
490
|
@wake_write.write_nonblock(".", exception: false)
|
|
432
491
|
rescue StandardError
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module Rubik
|
|
6
|
+
module Concurrency
|
|
7
|
+
# #102: Net::HTTP (and any stdlib code) enforces deadlines with the
|
|
8
|
+
# Timeout module — a watchdog Thread that Thread#raises the calling
|
|
9
|
+
# thread when the deadline expires. Under the scheduler that thread is
|
|
10
|
+
# the loop thread, shared by every parked fiber: the raise lands in
|
|
11
|
+
# whichever frame is executing (another fiber's IO.select, the minitest
|
|
12
|
+
# reporter), and after an abandoned drain it fires into unrelated
|
|
13
|
+
# runner code.
|
|
14
|
+
#
|
|
15
|
+
# While a boundary is draining on this thread, Timeout.timeout is
|
|
16
|
+
# fiber-native instead: the block runs inline (no watchdog thread is
|
|
17
|
+
# ever created) and the deadline exception is delivered through the
|
|
18
|
+
# loop's interrupt path at the calling fiber's current park. Outside a
|
|
19
|
+
# drain there is no loop to consume timers, so the stock watchdog
|
|
20
|
+
# applies — a thread-raise into the thread that is genuinely blocked is
|
|
21
|
+
# correctly targeted there.
|
|
22
|
+
module TimeoutPatch
|
|
23
|
+
def timeout(sec = nil, exception_class = nil, message = nil, &)
|
|
24
|
+
scheduler = ::Fiber.scheduler
|
|
25
|
+
if sec.is_a?(::Numeric) && sec.positive? &&
|
|
26
|
+
scheduler.is_a?(::Rubik::Concurrency::Scheduler) && scheduler.native_deadlines?
|
|
27
|
+
return scheduler.fiber_timeout(sec, exception_class, message, &)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
::Timeout.singleton_class.prepend(TimeoutPatch)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -75,6 +75,21 @@ module Rubik
|
|
|
75
75
|
@loop.resolve_address(hostname)
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
# True while the loop is actively draining on this thread — the only
|
|
79
|
+
# window in which Timeout deadlines can be driven by loop timers
|
|
80
|
+
# (outside it the stock Timeout watchdog applies).
|
|
81
|
+
def native_deadlines?
|
|
82
|
+
@loop.draining?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Runs block with a fiber-native deadline: the exception arrives at
|
|
86
|
+
# the fiber's current park instead of a watchdog Thread#raise into
|
|
87
|
+
# the loop thread (#102). Loop-thread only — the Timeout patch routes
|
|
88
|
+
# here exclusively from scheduled fibers.
|
|
89
|
+
def fiber_timeout(sec, exception_class, message, &)
|
|
90
|
+
@loop.fiber_timeout(sec, exception_class, message, &)
|
|
91
|
+
end
|
|
92
|
+
|
|
78
93
|
def scheduler_close
|
|
79
94
|
@loop.shutdown
|
|
80
95
|
nil
|
|
@@ -68,6 +68,24 @@ module Rubik
|
|
|
68
68
|
fired
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
# Removes a pending entry (identity comparison — entries are arrays
|
|
72
|
+
# that may compare structurally equal) and re-heapifies around the
|
|
73
|
+
# hole. Returns true when the entry was still pending. Only the loop
|
|
74
|
+
# thread mutates the heap and cancellation runs on the loop thread
|
|
75
|
+
# too, so a cancelled entry can never fire afterwards.
|
|
76
|
+
def cancel(entry)
|
|
77
|
+
index = @heap.index { |candidate| candidate.equal?(entry) }
|
|
78
|
+
return false unless index
|
|
79
|
+
|
|
80
|
+
last = @heap.pop
|
|
81
|
+
return true if index >= @heap.size
|
|
82
|
+
|
|
83
|
+
@heap[index] = last
|
|
84
|
+
sift_down(index)
|
|
85
|
+
sift_up(index)
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
71
89
|
private
|
|
72
90
|
|
|
73
91
|
def prune!
|
data/lib/rubik/concurrency.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "concurrency/event_loop"
|
|
|
6
6
|
require_relative "concurrency/scheduler"
|
|
7
7
|
require_relative "concurrency/boundary"
|
|
8
8
|
require_relative "concurrency/join"
|
|
9
|
+
require_relative "concurrency/fiber_timeout"
|
|
9
10
|
|
|
10
11
|
module Rubik
|
|
11
12
|
# Silent concurrency over Ruby's Fiber Scheduler: plain blocking I/O —
|
|
@@ -17,10 +18,12 @@ module Rubik
|
|
|
17
18
|
# Envelope (documented limits, plan §9): this is I/O concurrency, not CPU
|
|
18
19
|
# parallelism (the GVL still serializes computation); regular-file reads
|
|
19
20
|
# never park (read_nonblock on files returns immediately — they simply run
|
|
20
|
-
# serially, which is harmless)
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
21
|
+
# serially, which is harmless). Timeout.timeout-backed deadlines (Net::HTTP
|
|
22
|
+
# open/read) are fiber-native inside boundaries: the watchdog Thread#raise
|
|
23
|
+
# is replaced by a loop-timer delivery into the calling fiber, so it can
|
|
24
|
+
# never land in another fiber's frame or in post-shutdown runner code
|
|
25
|
+
# (concurrency/fiber_timeout.rb). Escape hatch: RUBIK_FIBER_SCHEDULER=off
|
|
26
|
+
# degrades every boundary to plain sequential execution.
|
|
24
27
|
module Concurrency
|
|
25
28
|
module_function
|
|
26
29
|
|
data/lib/rubik/lsp/client.rb
CHANGED
|
@@ -28,7 +28,7 @@ module Rubik
|
|
|
28
28
|
|
|
29
29
|
def initialize(language, root_dir: Dir.pwd, server_cmd: nil)
|
|
30
30
|
@language = language.to_s.downcase
|
|
31
|
-
@root_dir =
|
|
31
|
+
@root_dir = Rubik::Platform.expand_path(root_dir)
|
|
32
32
|
cmd = server_cmd || SERVERS[@language]
|
|
33
33
|
if (cmd.to_s.start_with?("typeprof") || cmd.to_s.start_with?("typescript-language-server")) && !cmd.to_s.include?("--stdio")
|
|
34
34
|
cmd = "#{cmd} --stdio"
|
|
@@ -232,7 +232,7 @@ module Rubik
|
|
|
232
232
|
def did_open(file_path, content, language_id = @language)
|
|
233
233
|
return false unless alive?
|
|
234
234
|
|
|
235
|
-
abs_path = Rubik::Platform.normalize_path_raw(
|
|
235
|
+
abs_path = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(file_path, @root_dir))
|
|
236
236
|
@mutex.synchronize do
|
|
237
237
|
@version_counter += 1
|
|
238
238
|
@open_documents[abs_path] = @version_counter
|
|
@@ -254,7 +254,7 @@ module Rubik
|
|
|
254
254
|
def did_change(file_path, content)
|
|
255
255
|
return false unless alive?
|
|
256
256
|
|
|
257
|
-
abs_path = Rubik::Platform.normalize_path_raw(
|
|
257
|
+
abs_path = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(file_path, @root_dir))
|
|
258
258
|
version = @mutex.synchronize do
|
|
259
259
|
@version_counter += 1
|
|
260
260
|
@open_documents[abs_path] = @version_counter
|
|
@@ -281,7 +281,7 @@ module Rubik
|
|
|
281
281
|
def did_close(file_path)
|
|
282
282
|
return false unless @running
|
|
283
283
|
|
|
284
|
-
abs_path = Rubik::Platform.normalize_path_raw(
|
|
284
|
+
abs_path = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(file_path, @root_dir))
|
|
285
285
|
@mutex.synchronize { @open_documents.delete(abs_path) }
|
|
286
286
|
|
|
287
287
|
send_notification("textDocument/didClose", {
|
|
@@ -295,14 +295,14 @@ module Rubik
|
|
|
295
295
|
def document_open?(file_path)
|
|
296
296
|
return false if file_path.nil? || file_path.to_s.strip.empty?
|
|
297
297
|
|
|
298
|
-
abs_path = Rubik::Platform.normalize_path_raw(
|
|
298
|
+
abs_path = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(file_path, @root_dir))
|
|
299
299
|
@mutex.synchronize { @open_documents.key?(abs_path) }
|
|
300
300
|
rescue ArgumentError
|
|
301
301
|
false
|
|
302
302
|
end
|
|
303
303
|
|
|
304
304
|
def diagnostics_for(file_path)
|
|
305
|
-
abs_path = Rubik::Platform.normalize_path_raw(
|
|
305
|
+
abs_path = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(file_path, @root_dir))
|
|
306
306
|
root_norm = Rubik::Platform.normalize_path_raw(@root_dir)
|
|
307
307
|
rel_path = abs_path.start_with?("#{root_norm}/") ? abs_path.delete_prefix("#{root_norm}/") : file_path.to_s.tr("\\", "/")
|
|
308
308
|
@mutex.synchronize do
|
|
@@ -381,7 +381,7 @@ module Rubik
|
|
|
381
381
|
params = msg["params"] || {}
|
|
382
382
|
uri = params["uri"].to_s
|
|
383
383
|
raw_path = Rubik::Platform.from_file_uri(uri)
|
|
384
|
-
abs_path = Rubik::Platform.normalize_path_raw(
|
|
384
|
+
abs_path = Rubik::Platform.normalize_path_raw(Rubik::Platform.expand_path(raw_path, @root_dir))
|
|
385
385
|
root_norm = Rubik::Platform.normalize_path_raw(@root_dir)
|
|
386
386
|
rel_path = abs_path.start_with?("#{root_norm}/") ? abs_path.delete_prefix("#{root_norm}/") : abs_path
|
|
387
387
|
diags = params["diagnostics"] || []
|
data/lib/rubik/platform/path.rb
CHANGED
|
@@ -65,7 +65,7 @@ module Rubik
|
|
|
65
65
|
# RFC 8089 Compliant File URI (e.g. file:///C:/path/file vs file:///path/file)
|
|
66
66
|
def to_file_uri(path)
|
|
67
67
|
normalized = normalize_path_raw(path)
|
|
68
|
-
if windows? && normalized =~ %r{^([a-zA-Z]):/(.*)$}
|
|
68
|
+
if (windows? || windows_shaped?(normalized)) && normalized =~ %r{^([a-zA-Z]):/(.*)$}
|
|
69
69
|
"file:///#{::Regexp.last_match(1)}:/#{::Regexp.last_match(2)}"
|
|
70
70
|
else
|
|
71
71
|
normalized.start_with?("/") ? "file://#{normalized}" : "file:///#{normalized}"
|
|
@@ -73,23 +73,49 @@ module Rubik
|
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
def from_file_uri(uri)
|
|
76
|
-
str = uri.to_s.sub(%r{
|
|
76
|
+
str = uri.to_s.sub(%r{\Afile://}, "")
|
|
77
77
|
str = CGI.unescape(str)
|
|
78
|
-
if windows?
|
|
79
|
-
# Handles file:///C:/... and file://C:/...
|
|
80
|
-
|
|
78
|
+
if windows? || str.match?(%r{\A/?[a-zA-Z]:/})
|
|
79
|
+
# Handles file:///C:/... and file://C:/... — keyed by SHAPE, so
|
|
80
|
+
# the same drive URIs resolve identically on a posix host (#103).
|
|
81
|
+
if str =~ %r{\A/?([a-zA-Z]:/.*)\z}
|
|
81
82
|
::Regexp.last_match(1)
|
|
82
83
|
else
|
|
83
84
|
str
|
|
84
85
|
end
|
|
86
|
+
elsif str.start_with?("/")
|
|
87
|
+
str
|
|
85
88
|
else
|
|
86
|
-
|
|
89
|
+
"/#{str}"
|
|
87
90
|
end
|
|
88
91
|
end
|
|
89
92
|
|
|
93
|
+
# Windows path SHAPE: a drive-letter root (C:/ or C:\). Host OS and
|
|
94
|
+
# path shape are independent — a windows-shaped path can reach a
|
|
95
|
+
# posix host (LSP payloads, fixtures, mounted drives) and its drive
|
|
96
|
+
# semantics must not depend on where the process happens to run
|
|
97
|
+
# (#103). Only drive-rooted paths count: a backslash is a legal
|
|
98
|
+
# character in a posix filename, so it never triggers windows
|
|
99
|
+
# semantics on its own.
|
|
100
|
+
def windows_shaped?(path)
|
|
101
|
+
path.to_s.match?(%r{\A[a-zA-Z]:[/\\]})
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# File.expand_path is host-OS-driven: on a posix host a drive-rooted
|
|
105
|
+
# windows path is treated as a RELATIVE name and mangled under cwd.
|
|
106
|
+
# A windows-shaped path carries its own root, so expansion is a no-op
|
|
107
|
+
# and only the separators normalize. Posix-shaped inputs and windows
|
|
108
|
+
# hosts take the stock path, byte-for-byte — including its TYPE
|
|
109
|
+
# strictness: callers' error paths depend on File.expand_path raising
|
|
110
|
+
# on non-strings, so never coerce here.
|
|
111
|
+
def expand_path(path, base = nil)
|
|
112
|
+
return path.tr("\\", "/") if !windows? && windows_shaped?(path)
|
|
113
|
+
|
|
114
|
+
::File.expand_path(path, base)
|
|
115
|
+
end
|
|
116
|
+
|
|
90
117
|
def normalize_path_raw(path)
|
|
91
|
-
|
|
92
|
-
expanded.gsub("\\", "/")
|
|
118
|
+
expand_path(path.to_s).gsub("\\", "/")
|
|
93
119
|
end
|
|
94
120
|
|
|
95
121
|
# Normalizes separators and casing for comparison
|
data/lib/rubik/security/guard.rb
CHANGED
|
@@ -257,6 +257,14 @@ module Rubik
|
|
|
257
257
|
|
|
258
258
|
def binary_allowlist_match?(arg, token)
|
|
259
259
|
return true if arg == token
|
|
260
|
+
# Windows exe-alias fast path (#103): on a windows host "node.exe"
|
|
261
|
+
# IS the allowlisted "node" — a lexical alias, no filesystem probe.
|
|
262
|
+
# The canonical-path probe below resolves through Platform.which +
|
|
263
|
+
# File.file?, a second host seam the windows? stub cannot cover (a
|
|
264
|
+
# linux host has no node.exe on disk, so the probe fails and the
|
|
265
|
+
# allowlist would dead-close). On a posix host the alias is FALSE:
|
|
266
|
+
# node.exe is a different binary than node there.
|
|
267
|
+
return true if windows_executable_alias?(arg, token)
|
|
260
268
|
|
|
261
269
|
c_arg = canonical_binary_path(arg)
|
|
262
270
|
c_token = canonical_binary_path(token)
|
|
@@ -266,6 +274,19 @@ module Rubik
|
|
|
266
274
|
false
|
|
267
275
|
end
|
|
268
276
|
|
|
277
|
+
# True when arg carries an executable extension from PATHEXT and its
|
|
278
|
+
# stem equals the token (case-insensitive — windows path semantics).
|
|
279
|
+
# Bare stems only: a path-form arg ("C:/x/node.exe") keeps going
|
|
280
|
+
# through the canonical-path probe, which resolves both sides.
|
|
281
|
+
def windows_executable_alias?(arg, token)
|
|
282
|
+
return false unless Rubik::Platform.windows?
|
|
283
|
+
|
|
284
|
+
ext = ::File.extname(arg)
|
|
285
|
+
return false if ext.empty? || !Rubik::Platform.pathext.include?(ext.downcase)
|
|
286
|
+
|
|
287
|
+
arg.delete_suffix(ext).casecmp?(token)
|
|
288
|
+
end
|
|
289
|
+
|
|
269
290
|
def command_matches_allowlist?(result)
|
|
270
291
|
# Missing argv (e.g. empty invocation) can never be allowlisted.
|
|
271
292
|
argv = result.respond_to?(:argv) ? result.argv : nil
|
data/lib/rubik/version.rb
CHANGED
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.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Özgür Ulukır
|
|
@@ -276,6 +276,7 @@ files:
|
|
|
276
276
|
- lib/rubik/concurrency/dns_pool.rb
|
|
277
277
|
- lib/rubik/concurrency/errors.rb
|
|
278
278
|
- lib/rubik/concurrency/event_loop.rb
|
|
279
|
+
- lib/rubik/concurrency/fiber_timeout.rb
|
|
279
280
|
- lib/rubik/concurrency/join.rb
|
|
280
281
|
- lib/rubik/concurrency/process_waiter.rb
|
|
281
282
|
- lib/rubik/concurrency/scheduler.rb
|