ace-runtime 0.1.1 → 0.3.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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.md +9 -0
  4. data/docs/usage.md +123 -0
  5. data/lib/ace/runtime/molecules/cgroup_observation.rb +148 -0
  6. data/lib/ace/runtime/molecules/codex_runtime_installation.rb +130 -0
  7. data/lib/ace/runtime/molecules/execution_boot_baseline.rb +134 -0
  8. data/lib/ace/runtime/molecules/execution_network_selection.rb +85 -0
  9. data/lib/ace/runtime/molecules/execution_unit_installation.rb +663 -0
  10. data/lib/ace/runtime/molecules/kernel_view_topology.rb +133 -0
  11. data/lib/ace/runtime/molecules/linux_mount_info.rb +101 -0
  12. data/lib/ace/runtime/molecules/network_installation_evidence.rb +332 -0
  13. data/lib/ace/runtime/molecules/process_identity.rb +123 -0
  14. data/lib/ace/runtime/molecules/protected_artifact_set.rb +211 -0
  15. data/lib/ace/runtime/molecules/protected_linux.rb +118 -0
  16. data/lib/ace/runtime/molecules/protected_socket.rb +119 -0
  17. data/lib/ace/runtime/molecules/protected_task_context_entry.rb +162 -0
  18. data/lib/ace/runtime/molecules/protected_worker_entry.rb +32 -0
  19. data/lib/ace/runtime/molecules/readiness_configuration.rb +154 -0
  20. data/lib/ace/runtime/molecules/readiness_runtime.rb +94 -0
  21. data/lib/ace/runtime/molecules/server_resource_observation.rb +183 -0
  22. data/lib/ace/runtime/molecules/systemd_scope_manager.rb +411 -0
  23. data/lib/ace/runtime/testing/adapter_contract.rb +9 -0
  24. data/lib/ace/runtime/version.rb +1 -1
  25. data/lib/ace/runtime.rb +3 -0
  26. data/native/README.md +124 -0
  27. data/native/build-worker-gate +20 -0
  28. data/native/worker_gate.c +385 -0
  29. metadata +63 -2
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "socket"
5
+ require "fiddle"
6
+
7
+ module Ace
8
+ module Runtime
9
+ module Molecules
10
+ # A PID is an address, not an identity. Keep only non-secret OS facts;
11
+ # command lines may contain credentials and are never journaled.
12
+ class ProcessIdentity
13
+ def initialize(executor: Open3, hostname: Socket.gethostname, birth_reader: nil)
14
+ @executor = executor
15
+ @hostname = hostname
16
+ @birth_reader = birth_reader || method(:native_birth)
17
+ end
18
+
19
+ def owner(shell_pid:, caller_pid:)
20
+ shell = capture(shell_pid)
21
+ return nil unless shell && caller_pid.is_a?(Integer) && caller_pid != shell_pid
22
+
23
+ seen = []
24
+ observations = []
25
+ current = caller_pid
26
+ child = nil
27
+ 64.times do
28
+ return nil if seen.include?(current)
29
+ seen << current
30
+ identity = capture(current)
31
+ return nil unless identity && identity["uid"] == shell["uid"]
32
+ parent = parent_pid(current)
33
+ return nil unless parent
34
+ observations << [identity, parent]
35
+ child = identity
36
+ if parent == shell_pid
37
+ return nil unless capture(shell_pid) == shell && observations.all? { |saved, ppid| capture(saved["pid"]) == saved && parent_pid(saved["pid"]) == ppid }
38
+ return {"shell_identity" => shell, "process_identity" => child}
39
+ end
40
+ current = parent
41
+ end
42
+ nil
43
+ end
44
+
45
+ def parent_pid(pid)
46
+ output, status = @executor.capture2("ps", "-p", pid.to_s, "-o", "ppid=", stdin_data: "")
47
+ value = output.strip
48
+ status.success? && value.match?(/\A[0-9]+\z/) ? value.to_i : nil
49
+ rescue SystemCallError, IOError
50
+ nil
51
+ end
52
+
53
+ def capture(pid)
54
+ return nil unless pid.is_a?(Integer) && pid.positive?
55
+
56
+ before = @birth_reader.call(pid)
57
+ return nil unless before
58
+ output, status = @executor.capture2("ps", "-p", pid.to_s, "-o", "pid=,uid=,stat=,lstart=", stdin_data: "")
59
+ return nil unless status.success?
60
+
61
+ match = output.strip.match(/\A(\d+)\s+(\d+)\s+(\S+)\s+(.+)\z/)
62
+ return nil unless match && match[1].to_i == pid && !match[3].match?(/[ZX]/)
63
+
64
+ birth = @birth_reader.call(pid)
65
+ return nil unless birth && birth == before
66
+
67
+ {"pid" => pid, "uid" => match[2].to_i, "started_at" => birth, "host" => @hostname}
68
+ rescue SystemCallError, IOError
69
+ nil
70
+ end
71
+
72
+ # Linux start ticks are scoped to the boot; Darwin exposes microsecond
73
+ # process birth through libproc's documented proc_bsdinfo structure.
74
+ # Platforms lacking either exact facility remain unobservable.
75
+ def native_birth(pid)
76
+ if RUBY_PLATFORM.include?("linux")
77
+ stat = File.read("/proc/#{pid}/stat").b
78
+ # comm is parenthesized and may itself contain parentheses or
79
+ # newlines. The final closing parenthesis precedes numeric fields.
80
+ match = stat.match(/\A#{pid} \(.*\) ([^\n]+)\n?\z/m)
81
+ return nil unless match
82
+ fields = match[1].split
83
+ ticks = fields[19]
84
+ return nil unless fields[0]&.match?(/\A[A-Za-z]\z/) && ticks&.match?(/\A[0-9]+\z/)
85
+ boot = File.read("/proc/sys/kernel/random/boot_id").strip
86
+ return nil if boot.empty?
87
+ "linux:#{boot}:#{ticks}"
88
+ elsif RUBY_PLATFORM.include?("darwin")
89
+ library = Fiddle.dlopen("/usr/lib/libproc.dylib")
90
+ function = Fiddle::Function.new(library["proc_pidinfo"],
91
+ [Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
92
+ buffer = Fiddle::Pointer.malloc(136)
93
+ return nil unless function.call(pid, 3, 0, buffer, 136) == 136
94
+ seconds, micros = buffer[120, 16].unpack("Q2")
95
+ return nil unless seconds.positive? && micros < 1_000_000
96
+ "darwin:#{seconds}:#{micros}"
97
+ end
98
+ rescue SystemCallError, IOError, IndexError, Fiddle::DLError
99
+ nil
100
+ end
101
+
102
+ def observe(identity)
103
+ at = Time.now.utc.iso8601
104
+ unless identity.is_a?(Hash) && identity["host"] == @hostname &&
105
+ identity["started_at"].is_a?(String) && identity["uid"].is_a?(Integer)
106
+ return {"liveness" => "unknown", "observed_at" => at, "reason" => "verified process identity unavailable"}
107
+ end
108
+
109
+ live = capture(identity["pid"])
110
+ if live == identity
111
+ {"liveness" => "live", "observed_at" => at, "reason" => "process identity matches", "identity" => live}
112
+ elsif live
113
+ {"liveness" => "unknown", "observed_at" => at, "reason" => "process address reused"}
114
+ else
115
+ # An inaccessible ps result cannot prove death or absence of
116
+ # descendants. Recovery retains the worktree and writer slot.
117
+ {"liveness" => "unknown", "observed_at" => at, "reason" => "process identity no longer observable"}
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "cgroup_observation"
5
+ require_relative "../errors"
6
+ require_relative "protected_socket"
7
+
8
+ module Ace
9
+ module Runtime
10
+ module Molecules
11
+ # Content evidence only. Retains every artifact and ancestor descriptor for
12
+ # one bounded verification, then rechecks their identities before closing.
13
+ class ProtectedArtifactSet
14
+ LIMIT = 1_048_576
15
+ TOTAL_LIMIT = 16 * LIMIT
16
+ COUNT_LIMIT = 256
17
+
18
+ class Protection
19
+ # These local Linux filesystems enforce POSIX mode/ACL mask semantics.
20
+ # Other models (including remote/FUSE filesystems) are not presumed safe.
21
+ FILESYSTEMS = %w[ext2 ext3 ext4 xfs btrfs tmpfs].freeze
22
+
23
+ def initialize(mounts: CgroupObservation::KernelFiles.new)
24
+ @mounts = mounts
25
+ end
26
+
27
+ def root_path!(path)
28
+ ProtectedSocket.root_path!(path)
29
+ end
30
+
31
+ def verify!(path, handle, directory:)
32
+ stat = handle.stat
33
+ unless trusted_owner?(stat) && (stat.mode & 0o022).zero? &&
34
+ (directory ? stat.directory? : stat.file?)
35
+ raise RuntimeUnavailableError, "network artifact ownership, mode or kind is unsafe"
36
+ end
37
+ unless FILESYSTEMS.include?(@mounts.mount_identity(handle).fetch("filesystem_type"))
38
+ raise RuntimeUnavailableError, "network artifact filesystem protection is unsupported"
39
+ end
40
+ # On these filesystems group bits are the access ACL mask: with no
41
+ # group/other write, no named non-root user/group can write either.
42
+ # Default ACLs grant no access to the existing descriptor. Every
43
+ # referenced file and ancestor is checked, including inherited mode.
44
+ end
45
+
46
+ private
47
+
48
+ def trusted_owner?(stat) = stat.uid.zero?
49
+ end
50
+
51
+ def initialize(protection: Protection.new, file_limit: LIMIT, total_limit: TOTAL_LIMIT, count_limit: COUNT_LIMIT)
52
+ unless file_limit.is_a?(Integer) && total_limit.is_a?(Integer) &&
53
+ file_limit.positive? && total_limit.positive? && file_limit <= total_limit
54
+ raise ArgumentError, "invalid protected artifact byte budgets"
55
+ end
56
+ unless count_limit.is_a?(Integer) && count_limit.positive?
57
+ raise ArgumentError, "invalid protected artifact count budget"
58
+ end
59
+ @protection = protection
60
+ @file_limit, @total_limit = file_limit, total_limit
61
+ @count_limit = count_limit
62
+ end
63
+
64
+ def with
65
+ @handles, @references, @contents, @total = {}, {}, {}, 0
66
+ yield self
67
+ ensure
68
+ @handles&.each_value { |entry| entry.fetch(:handle).close }
69
+ @handles = @references = @contents = nil
70
+ end
71
+
72
+ def read!(reference)
73
+ path = reference.fetch("path")
74
+ previous = @references[path]
75
+ if previous
76
+ raise RuntimeUnavailableError, "network artifact references conflict" unless previous == reference
77
+ return @contents.fetch(path)
78
+ end
79
+ raise RuntimeUnavailableError, "network artifact graph is oversized" if @references.size >= @count_limit
80
+ size = reference.fetch("bytes")
81
+ unless size.is_a?(Integer) && size.between?(1, @file_limit) && @total + size <= @total_limit
82
+ raise RuntimeUnavailableError, "network artifact bytes exceed bounds"
83
+ end
84
+ @protection.root_path!(path)
85
+ ancestors(path).reverse_each { |ancestor| pin!(ancestor, directory: true) }
86
+ handle = pin!(path, directory: false)
87
+ unless handle.stat.size == size
88
+ raise RuntimeUnavailableError, "network artifact declared length differs"
89
+ end
90
+ bytes = handle.read(size + 1)
91
+ unless bytes.is_a?(String) && bytes.bytesize == size &&
92
+ Digest::SHA256.hexdigest(bytes) == reference.fetch("sha256")
93
+ raise RuntimeUnavailableError, "network artifact content differs"
94
+ end
95
+ @references[path] = reference.dup.freeze
96
+ @contents[path] = bytes.freeze
97
+ @total += size
98
+ bytes
99
+ end
100
+
101
+ # Trusted process handoff inside the same bounded verification. The
102
+ # duplicate refers to the authenticated retained inode, never a reopened
103
+ # pathname, and is closed before this callback can outlive the set.
104
+ def with_readonly_handle!(reference)
105
+ unless @handles && @references
106
+ raise RuntimeUnavailableError, "protected handle requires an active verification"
107
+ end
108
+ read!(reference)
109
+ verify_unchanged!
110
+ duplicate = @handles.fetch(reference.fetch("path")).fetch(:handle).dup
111
+ duplicate.close_on_exec = true
112
+ duplicate.rewind
113
+ begin
114
+ yield duplicate
115
+ ensure
116
+ verify_unchanged!
117
+ end
118
+ ensure
119
+ duplicate&.close unless duplicate&.closed?
120
+ end
121
+
122
+ # Fixed installer entrypoints have no caller-supplied digest. Derive the
123
+ # reference from the same protected descriptor whose bytes are retained.
124
+ def read_path!(path, limit: @file_limit)
125
+ raise ArgumentError, "invalid artifact byte limit" unless limit.is_a?(Integer) && limit.between?(1, @file_limit)
126
+ raise ArgumentError, "invalid fixed artifact path" unless path.is_a?(String) &&
127
+ path.start_with?("/") && !path.include?("\0") && File.expand_path(path) == path
128
+ if @references.key?(path)
129
+ reference = @references.fetch(path)
130
+ raise RuntimeUnavailableError, "fixed artifact exceeds bounds" if reference.fetch("bytes") > limit
131
+ return [@contents.fetch(path), reference].freeze
132
+ end
133
+ @protection.root_path!(path)
134
+ ancestors(path).reverse_each { |ancestor| pin!(ancestor, directory: true) }
135
+ handle = pin!(path, directory: false)
136
+ size = handle.stat.size
137
+ unless size.between?(1, limit) && @references.size < @count_limit && @total + size <= @total_limit
138
+ raise RuntimeUnavailableError, "fixed artifact exceeds bounds"
139
+ end
140
+ bytes = handle.read(size + 1)
141
+ unless bytes.is_a?(String) && bytes.bytesize == size
142
+ raise RuntimeUnavailableError, "fixed artifact length changed"
143
+ end
144
+ reference = {"path" => path.dup.freeze, "sha256" => Digest::SHA256.hexdigest(bytes).freeze,
145
+ "bytes" => size}.freeze
146
+ if @references.key?(path)
147
+ raise RuntimeUnavailableError, "fixed artifact references conflict" unless @references.fetch(path) == reference
148
+ else
149
+ @references[path] = reference
150
+ @contents[path] = bytes.freeze
151
+ @total += size
152
+ end
153
+ [@contents.fetch(path), reference].freeze
154
+ end
155
+
156
+ def verify_unchanged!
157
+ @handles.each do |path, entry|
158
+ handle = entry.fetch(:handle)
159
+ unless snapshot(handle.stat, directory: entry.fetch(:directory)) == entry.fetch(:snapshot) &&
160
+ snapshot(File.lstat(path), directory: entry.fetch(:directory)) == entry.fetch(:snapshot)
161
+ raise RuntimeUnavailableError, "network artifact changed during verification"
162
+ end
163
+ @protection.verify!(path, handle, directory: entry.fetch(:directory))
164
+ end
165
+ true
166
+ end
167
+
168
+ private
169
+
170
+ def ancestors(path)
171
+ result = []
172
+ current = File.dirname(path)
173
+ loop do
174
+ result << current
175
+ break if current == "/"
176
+ current = File.dirname(current)
177
+ end
178
+ result
179
+ end
180
+
181
+ def pin!(path, directory:)
182
+ return @handles.fetch(path).fetch(:handle) if @handles.key?(path)
183
+ stat = File.lstat(path)
184
+ unless !stat.symlink? && (directory ? stat.directory? : stat.file?)
185
+ raise RuntimeUnavailableError, "network artifact kind or link is unsafe"
186
+ end
187
+ # NONBLOCK also prevents blocking if a hostile special file replaces a
188
+ # regular file between lstat and open; descriptor type is checked below.
189
+ handle = File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
190
+ begin
191
+ handle.close_on_exec = true
192
+ @protection.verify!(path, handle, directory: directory)
193
+ unless snapshot(stat, directory: directory) == snapshot(handle.stat, directory: directory) && snapshot(File.lstat(path), directory: directory) == snapshot(handle.stat, directory: directory)
194
+ raise RuntimeUnavailableError, "network artifact changed while opening"
195
+ end
196
+ @handles[path] = {handle: handle, directory: directory, snapshot: snapshot(handle.stat, directory: directory)}
197
+ handle
198
+ rescue Exception
199
+ handle.close
200
+ raise
201
+ end
202
+ end
203
+
204
+ def snapshot(stat, directory:)
205
+ identity = [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode]
206
+ directory ? identity : identity + [stat.size, stat.mtime, stat.ctime]
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "socket"
4
+ require "fiddle"
5
+ require_relative "process_identity"
6
+
7
+ module Ace
8
+ module Runtime
9
+ module Molecules
10
+ # Kernel observations for protected launch. No absence inferred from ps.
11
+ class ProtectedLinux
12
+ def initialize(identity: ProcessIdentity.new)
13
+ @identity = identity
14
+ end
15
+
16
+ def supported!
17
+ unless RUBY_PLATFORM.include?("linux") && File.read("/proc/sys/kernel/yama/ptrace_scope").strip == "2"
18
+ raise RuntimeUnavailableError, "protected launch requires Linux Yama ptrace_scope=2"
19
+ end
20
+ probe = pidfd_handle(Process.pid)
21
+ raise RuntimeUnavailableError, "pidfd self observation is invalid" if exited?(probe)
22
+ true
23
+ rescue SystemCallError, IOError
24
+ raise RuntimeUnavailableError, "protected process policy is unavailable"
25
+ ensure
26
+ probe&.close
27
+ end
28
+
29
+ def capture(pid)
30
+ before = @identity.capture(pid)
31
+ raise RuntimeUnavailableError, "exact kernel process is unavailable" unless before
32
+ fields = File.readlines("/proc/#{pid}/status").to_h { |line| line.strip.split(/:\s*/, 2) }
33
+ ids = %w[Uid Gid].to_h { |name| [name, fields.fetch(name).split.map { |v| Integer(v, 10) }] }
34
+ caps = %w[CapInh CapPrm CapEff CapBnd CapAmb].to_h { |name| [name, Integer(fields.fetch(name), 16)] }
35
+ unless @identity.capture(pid) == before && ids.fetch("Uid").first == before.fetch("uid") && ids.values.all? { |v| v.size == 4 && v.uniq.size == 1 } &&
36
+ caps.values.all?(&:zero?) && fields.fetch("NoNewPrivs") == "1"
37
+ raise RuntimeUnavailableError, "process identity or capability policy is unsafe"
38
+ end
39
+ before.merge("gid" => ids.fetch("Gid").first,
40
+ "groups" => fields.fetch("Groups", "").split.map { |v| Integer(v, 10) }.sort,
41
+ "parent_pid" => Integer(fields.fetch("PPid"), 10))
42
+ rescue SystemCallError, KeyError, ArgumentError
43
+ raise RuntimeUnavailableError, "kernel process policy is unreadable"
44
+ end
45
+
46
+ def peer(socket)
47
+ raise RuntimeUnavailableError, "protected socket peers require Linux" unless RUBY_PLATFORM.include?("linux")
48
+ pid, uid, gid = socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_PEERCRED).data.unpack("i!3")
49
+ value = capture(pid)
50
+ unless value["uid"] == uid && value["gid"] == gid
51
+ raise RuntimeUnavailableError, "socket peer changed kernel identity"
52
+ end
53
+ value
54
+ end
55
+
56
+ def pin(identity)
57
+ handle = pidfd_handle(identity.fetch("pid"))
58
+ begin
59
+ unless same?(identity, capture(identity.fetch("pid")))
60
+ raise RuntimeUnavailableError, "process changed while acquiring pidfd"
61
+ end
62
+ handle
63
+ rescue Exception
64
+ handle.close
65
+ raise
66
+ end
67
+ end
68
+
69
+ def same?(left, right)
70
+ %w[pid uid gid started_at host groups parent_pid].all? { |key| left[key] == right[key] }
71
+ end
72
+
73
+ # Socket peer ancestry, with every PID incarnation rechecked after the
74
+ # walk. Same UID alone never attributes a sibling to the native child.
75
+ def descendant?(identity, ancestor)
76
+ observations = []
77
+ current = identity.fetch("pid")
78
+ 64.times do
79
+ return false if observations.any? { |item| item.fetch("pid") == current }
80
+ observed = capture(current)
81
+ return false unless observed["uid"] == ancestor["uid"]
82
+ return false if observations.empty? && !same?(identity, observed)
83
+ observations << observed
84
+ if current == ancestor.fetch("pid")
85
+ return same?(ancestor, observed) && observations.all? { |saved| same?(saved, capture(saved.fetch("pid"))) }
86
+ end
87
+ current = observed.fetch("parent_pid")
88
+ return false unless current.is_a?(Integer) && current.positive?
89
+ end
90
+ false
91
+ rescue RuntimeUnavailableError, KeyError
92
+ false
93
+ end
94
+
95
+ def live!(identity)
96
+ raise RuntimeUnavailableError, "exact process identity changed" unless same?(identity, capture(identity.fetch("pid")))
97
+ true
98
+ end
99
+
100
+ def exited?(handle, timeout: 0)
101
+ !IO.select([handle], nil, nil, timeout).nil?
102
+ end
103
+
104
+ private
105
+
106
+ def pidfd_handle(pid)
107
+ library = Fiddle.dlopen(nil)
108
+ call = Fiddle::Function.new(library["pidfd_open"], [Fiddle::TYPE_INT, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
109
+ fd = call.call(pid, 0)
110
+ raise RuntimeUnavailableError, "exact pidfd is unavailable" if fd.negative?
111
+ IO.for_fd(fd, autoclose: true)
112
+ rescue Fiddle::DLError
113
+ raise RuntimeUnavailableError, "exact pidfd capability is unsupported"
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+ require "socket"
3
+ require "json"
4
+
5
+ module Ace
6
+ module Runtime
7
+ module Molecules
8
+ module ProtectedSocket
9
+ LIMIT = 65_536
10
+ # Fixed protocols that forbid descriptors use this for every control,
11
+ # binary-body and EOF read; rights never bypass a later framing check.
12
+ class Ingress
13
+ def initialize(socket) = @socket = socket
14
+ def to_io = @socket
15
+
16
+ def read_nonblock(length, exception: false)
17
+ result = @socket.recvmsg_nonblock(length, 0, 4096, scm_rights: true, exception: exception)
18
+ return result if result == :wait_readable
19
+ return nil if result.nil?
20
+ bytes, _, flags, *controls = result
21
+ controls.each { |control| control.unix_rights&.each(&:close) if control.cmsg_is?(Socket::SOL_SOCKET, Socket::SCM_RIGHTS) }
22
+ raise SecurityError, "protected ancillary input is forbidden" unless controls.empty? && (flags & Socket::MSG_CTRUNC).zero?
23
+ bytes.empty? ? nil : bytes
24
+ end
25
+ end
26
+ module_function
27
+
28
+ def root_path!(path, directory: false, owner: 0)
29
+ unless path.is_a?(String) && path.start_with?("/") && File.expand_path(path) == path
30
+ raise RuntimeUnavailableError, "protected path must be canonical and absolute"
31
+ end
32
+ current = path
33
+ loop do
34
+ stat = File.lstat(current)
35
+ unless !stat.symlink? && (current == path ? stat.uid == owner : [0, owner].include?(stat.uid)) && (stat.mode & 0o022).zero?
36
+ raise RuntimeUnavailableError, "protected path ownership or ancestry is unsafe"
37
+ end
38
+ if current != path && !stat.directory?
39
+ raise RuntimeUnavailableError, "protected ancestor is not a directory"
40
+ end
41
+ if current == path && directory && !stat.directory?
42
+ raise RuntimeUnavailableError, "protected root is not a directory"
43
+ end
44
+ break if current == "/"
45
+ current = File.dirname(current)
46
+ end
47
+ true
48
+ rescue SystemCallError
49
+ raise RuntimeUnavailableError, "protected path is unavailable"
50
+ end
51
+
52
+ def connect(path, deadline: self.deadline)
53
+ socket = Socket.new(Socket::AF_UNIX, Socket::SOCK_STREAM, 0)
54
+ socket.close_on_exec = true
55
+ result = socket.connect_nonblock(Socket.sockaddr_un(path), exception: false)
56
+ if result == :wait_writable
57
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
58
+ unless remaining.positive? && IO.select(nil, [socket], nil, remaining) && socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int.zero?
59
+ raise RuntimeUnavailableError, "protected socket connection deadline expired"
60
+ end
61
+ end
62
+ yield socket
63
+ ensure
64
+ socket&.close unless socket&.closed?
65
+ end
66
+
67
+ def socket_identity(path, mode: nil)
68
+ stat = File.lstat(path)
69
+ raise RuntimeUnavailableError, "protected endpoint is not a socket" unless stat.socket? && !stat.symlink?
70
+ if !mode.nil? && (!mode.is_a?(Integer) || !mode.between?(0, 0o7777) || (stat.mode & 0o7777) != mode)
71
+ raise RuntimeUnavailableError, "protected endpoint mode differs"
72
+ end
73
+ [stat.dev, stat.ino, stat.uid]
74
+ rescue SystemCallError
75
+ raise RuntimeUnavailableError, "protected endpoint is unavailable"
76
+ end
77
+
78
+ def read(socket, deadline:, limit: LIMIT, with_size: false)
79
+ buffer = +""
80
+ loop do
81
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
+ raise RuntimeUnavailableError, "protected socket deadline expired" unless remaining.positive? &&
83
+ IO.select([socket], nil, nil, remaining)
84
+ chunk = socket.read_nonblock(1, exception: false)
85
+ next if chunk == :wait_readable
86
+ raise RuntimeUnavailableError, "protected socket closed before response" unless chunk
87
+ buffer << chunk
88
+ raise RuntimeUnavailableError, "protected socket frame is oversized" if buffer.bytesize > limit
89
+ break if chunk == "\n"
90
+ end
91
+ buffer.force_encoding(Encoding::UTF_8)
92
+ raise RuntimeUnavailableError, "protected socket frame is malformed" unless buffer.valid_encoding?
93
+ value = JSON.parse(buffer, create_additions: false, max_nesting: 32,
94
+ allow_duplicate_key: false, allow_comments: false)
95
+ with_size ? {data: value, bytesize: buffer.bytesize} : value
96
+ rescue JSON::ParserError
97
+ raise RuntimeUnavailableError, "protected socket frame is malformed"
98
+ end
99
+
100
+ def write(socket, value, deadline:, limit: LIMIT)
101
+ bytes = JSON.generate(value) + "\n"
102
+ raise RuntimeUnavailableError, "protected socket frame is oversized" if bytes.bytesize > limit
103
+ until bytes.empty?
104
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
105
+ raise RuntimeUnavailableError, "protected socket deadline expired" unless remaining.positive? &&
106
+ IO.select(nil, [socket], nil, remaining)
107
+ sent = socket.write_nonblock(bytes, exception: false)
108
+ next if sent == :wait_writable
109
+ bytes = bytes.byteslice(sent..)
110
+ end
111
+ end
112
+
113
+ def deadline(seconds = 5)
114
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end