ace-runtime 0.1.1 → 0.2.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.
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module Ace
6
+ module Runtime
7
+ module Molecules
8
+ # Decodes actual Linux mountinfo records. Paths alone do not select a
9
+ # visible mount; callers must use the pinned descriptor's mnt_id.
10
+ class LinuxMountInfo
11
+ LIMIT = 1_048_576
12
+ ESCAPES = {"040" => " ", "011" => "\t", "012" => "\n", "134" => "\\"}.freeze
13
+ attr_reader :records
14
+
15
+ def initialize(bytes)
16
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, LIMIT) && !bytes.include?("\0")
17
+ unavailable!("kernel mount table is unavailable")
18
+ end
19
+ @records = bytes.lines.map do |line|
20
+ fields = line.chomp.split(" ")
21
+ separator = fields.index("-")
22
+ unless separator && separator >= 6 && fields.size == separator + 4 &&
23
+ fields[0].match?(/\A[1-9][0-9]*\z/) && fields[1].match?(/\A[0-9]+\z/) &&
24
+ fields[2].match?(/\A[0-9]+:[0-9]+\z/) &&
25
+ fields[6...separator].all? { |field| field == "unbindable" || field.match?(/\A(?:shared|master|propagate_from):[1-9][0-9]*\z/) }
26
+ unavailable!("kernel mount table is malformed")
27
+ end
28
+ root, mountpoint = fields.values_at(3, 4).map { |value| decode(value) }
29
+ filesystem = fields[separator + 1]
30
+ root_valid = root.start_with?("/") && File.expand_path(root) == root
31
+ root_valid ||= filesystem == "nsfs" && root.match?(/\A(?:net|mnt|user|pid|uts|ipc|cgroup|time):\[[1-9][0-9]*\]\z/)
32
+ unless root_valid && mountpoint.start_with?("/") && File.expand_path(mountpoint) == mountpoint &&
33
+ filesystem.match?(/\A[a-zA-Z0-9_.-]+\z/)
34
+ unavailable!("kernel mount paths/filesystem are malformed")
35
+ end
36
+ {"mount_id" => Integer(fields[0], 10), "parent_id" => Integer(fields[1], 10),
37
+ "major_minor" => fields[2], "root" => root, "mountpoint" => mountpoint,
38
+ "options" => options(fields[5]), "optional_fields" => fields[6...separator],
39
+ "filesystem_type" => fields[separator + 1], "source" => decode(fields[separator + 2]),
40
+ "super_options" => options(fields[separator + 3])}.then { |record| freeze_tree(record) }
41
+ end.freeze
42
+ unless records.map { |record| record.fetch("mount_id") }.uniq.size == records.size
43
+ unavailable!("kernel mount IDs are duplicate")
44
+ end
45
+ end
46
+
47
+ def by_id(mount_id)
48
+ record = records.find { |entry| entry.fetch("mount_id") == mount_id }
49
+ unavailable!("pinned object mount is missing") unless record
50
+ record
51
+ end
52
+
53
+ def filesystem_path(record, path)
54
+ unavailable!("namespace object has no ordinary backing filesystem path") unless record.fetch("filesystem_type") != "nsfs" && record.fetch("root").start_with?("/")
55
+ prefix = record.fetch("mountpoint")
56
+ unless path.is_a?(String) && File.expand_path(path) == path &&
57
+ (path == prefix || prefix == "/" && path.start_with?("/") || path.start_with?(prefix + "/"))
58
+ unavailable!("object is outside its descriptor mount")
59
+ end
60
+ relative = prefix == "/" ? path.delete_prefix("/") : path.delete_prefix(prefix).delete_prefix("/")
61
+ relative.empty? ? record.fetch("root") : File.join(record.fetch("root"), relative)
62
+ end
63
+
64
+ private
65
+
66
+ def freeze_tree(value)
67
+ case value
68
+ when Hash then value.each { |key, item| key.freeze; freeze_tree(item) }
69
+ when Array then value.each { |item| freeze_tree(item) }
70
+ end
71
+ value.freeze
72
+ end
73
+
74
+ def options(value)
75
+ parts = value.split(",", -1)
76
+ unavailable!("kernel mount options are malformed") if parts.any?(&:empty?) || parts.uniq.size != parts.size
77
+ parts.freeze
78
+ end
79
+
80
+ def decode(value)
81
+ value.gsub(/\\([^ ]*)/) do
82
+ remaining = Regexp.last_match(1)
83
+ result = +""
84
+ until remaining.empty?
85
+ code = remaining.slice(0, 3)
86
+ unavailable!("kernel mount path escape is malformed") unless ESCAPES.key?(code)
87
+ result << ESCAPES.fetch(code)
88
+ remaining = remaining.slice(3..)
89
+ literal, escaped = remaining.split("\\", 2)
90
+ result << literal
91
+ remaining = escaped || ""
92
+ end
93
+ result
94
+ end
95
+ end
96
+
97
+ def unavailable!(message) = raise(RuntimeUnavailableError, message)
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,332 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ipaddr"
5
+ require "date"
6
+ require_relative "protected_artifact_set"
7
+
8
+ module Ace
9
+ module Runtime
10
+ module Molecules
11
+ # Authenticates bounded root-produced installation content. Actual policy
12
+ # comparison/flow observations belong to the trusted domain producer; the
13
+ # installed owner independently pins the nsfs object and supplies expected.
14
+ class NetworkInstallationEvidence
15
+ ID = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/
16
+ SHA = /\A[0-9a-f]{64}\z/
17
+ UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
18
+ FAMILIES = %w[ipv4 ipv6].freeze
19
+ PROFILE_KEYS = %w[version profile_id slot_id namespace_path families protected_controls loopback_tools
20
+ credential_profiles].freeze
21
+ REPORT_KEYS = %w[version report_id profile_id slot_id boot_id namespace_path namespace_identity profile_sha256
22
+ policy_export_sha256 observed_topology installer_artifact_sha256 producer_source_sha
23
+ completed_at checks].freeze
24
+ TRACE_KEYS = %w[version report_id check_id family target_id boot_id namespace_identity profile_sha256
25
+ policy_export_sha256 producer_artifact_sha256 observations].freeze
26
+ EXPECTED_KEYS = %w[slot_id namespace_path boot_id namespace_identity installer_artifact_sha256].freeze
27
+
28
+ def self.verify!(selection:, expected:)
29
+ new.verify!(selection: selection, expected: expected)
30
+ end
31
+
32
+ # The injectable content reader is a source-test seam, never a runtime
33
+ # transport selector or alternate trust root.
34
+ def initialize(artifacts: ProtectedArtifactSet.new)
35
+ @artifacts = artifacts
36
+ end
37
+
38
+ def verify!(selection:, expected:)
39
+ object!(selection, %w[profile policy_export report installer_artifact])
40
+ expected!(expected)
41
+ @edges, @refs = Hash.new { |hash, path| hash[path] = [] }, {}
42
+ @artifacts.with do
43
+ selection.each_value { |ref| artifact!(ref) }
44
+ unless selection.fetch("installer_artifact").fetch("sha256") == expected.fetch("installer_artifact_sha256")
45
+ refuse!("selected network producer differs")
46
+ end
47
+ profile = json!(selection.fetch("profile"))
48
+ profile!(profile, expected, selection.fetch("profile").fetch("path"))
49
+ report = json!(selection.fetch("report"))
50
+ report!(report, profile, selection, expected)
51
+ graph_acyclic!
52
+ @artifacts.verify_unchanged!
53
+ result = report.slice("report_id", "boot_id", "slot_id", "namespace_path", "namespace_identity",
54
+ "profile_sha256", "policy_export_sha256", "installer_artifact_sha256")
55
+ result["report_sha256"] = selection.fetch("report").fetch("sha256")
56
+ immutable(result)
57
+ end
58
+ rescue SystemCallError, IOError, JSON::ParserError, IPAddr::Error, KeyError, TypeError, ArgumentError
59
+ refuse!("network installation evidence cannot be verified")
60
+ ensure
61
+ @edges = @refs = nil
62
+ end
63
+
64
+ private
65
+
66
+ def expected!(value)
67
+ object!(value, EXPECTED_KEYS)
68
+ id!(value.fetch("slot_id"))
69
+ path!(value.fetch("namespace_path"))
70
+ format!(value.fetch("boot_id"), UUID)
71
+ identity!(value.fetch("namespace_identity"))
72
+ format!(value.fetch("installer_artifact_sha256"), SHA)
73
+ end
74
+
75
+ def profile!(value, expected, profile_path)
76
+ object!(value, PROFILE_KEYS)
77
+ version!(value)
78
+ %w[profile_id slot_id].each { |key| id!(value.fetch(key)) }
79
+ path!(value.fetch("namespace_path"))
80
+ %w[slot_id namespace_path].each { |key| equal!(value.fetch(key), expected.fetch(key)) }
81
+ families = collection!(value.fetch("families"), "family", min: 2)
82
+ equal!(families.map { |entry| entry.fetch("family") }.sort, FAMILIES)
83
+ families.each do |entry|
84
+ object!(entry, %w[family mode])
85
+ enum!(entry.fetch("mode"), %w[enabled disabled])
86
+ end
87
+ controls = collection!(value.fetch("protected_controls"), "id")
88
+ tools = collection!(value.fetch("loopback_tools"), "id")
89
+ credentials = collection!(value.fetch("credential_profiles"), "id")
90
+ entries = families.size + controls.size + tools.size + credentials.size
91
+ refuse!("network profile collections are oversized") if entries > 256
92
+ controls.each { |entry| control!(entry) }
93
+ tools.each { |entry| loopback!(entry) }
94
+ credentials.each { |entry| credential!(entry, profile_path) }
95
+ end
96
+
97
+ def control!(entry)
98
+ object!(entry, %w[id family kind address_ranges protocol ports])
99
+ id!(entry.fetch("id"))
100
+ enum!(entry.fetch("family"), FAMILIES)
101
+ enum!(entry.fetch("kind"), %w[host_process internal_process protected_storage])
102
+ enum!(entry.fetch("protocol"), %w[tcp udp])
103
+ ranges = array!(entry.fetch("address_ranges"), min: 1)
104
+ unique!(ranges)
105
+ ranges.each { |range| cidr!(range, entry.fetch("family")) }
106
+ ports = entry.fetch("ports")
107
+ unless ports == "all"
108
+ array!(ports, min: 1).each { |port| port!(port) }
109
+ unique!(ports)
110
+ end
111
+ end
112
+
113
+ def loopback!(entry)
114
+ object!(entry, %w[id family protocol address port artifact_sha256])
115
+ id!(entry.fetch("id"))
116
+ enum!(entry.fetch("family"), FAMILIES)
117
+ enum!(entry.fetch("protocol"), %w[tcp udp])
118
+ equal!(entry.fetch("address"), entry.fetch("family") == "ipv4" ? "127.0.0.1" : "::1")
119
+ port!(entry.fetch("port"))
120
+ format!(entry.fetch("artifact_sha256"), SHA)
121
+ end
122
+
123
+ def credential!(entry, profile_path)
124
+ object!(entry, %w[id kind helper_artifacts configuration_artifacts authority])
125
+ id!(entry.fetch("id"))
126
+ enum!(entry.fetch("kind"), %w[provider dns remote_git])
127
+ equal!(entry.fetch("authority"), "remote_only")
128
+ %w[helper_artifacts configuration_artifacts].each do |key|
129
+ refs = array!(entry.fetch(key))
130
+ unique!(refs.map { |ref| artifact!(ref, parent: profile_path); ref.fetch("path") })
131
+ end
132
+ end
133
+
134
+ def report!(value, profile, selection, expected)
135
+ object!(value, REPORT_KEYS)
136
+ version!(value)
137
+ %w[report_id boot_id].each { |key| format!(value.fetch(key), UUID) }
138
+ %w[profile_id slot_id].each { |key| id!(value.fetch(key)); equal!(value.fetch(key), profile.fetch(key)) }
139
+ %w[namespace_path boot_id namespace_identity installer_artifact_sha256].each do |key|
140
+ equal!(value.fetch(key), expected.fetch(key))
141
+ end
142
+ identity!(value.fetch("namespace_identity"))
143
+ %w[profile policy_export installer_artifact].each do |key|
144
+ format!(value.fetch("#{key}_sha256"), SHA)
145
+ equal!(value.fetch("#{key}_sha256"), selection.fetch(key).fetch("sha256"))
146
+ end
147
+ format!(value.fetch("producer_source_sha"), /\A[0-9a-f]{40}\z/)
148
+ timestamp!(value.fetch("completed_at"))
149
+ artifact!(value.fetch("observed_topology"), parent: selection.fetch("report").fetch("path"))
150
+ checks = array!(value.fetch("checks"), min: 1)
151
+ actual = checks.map do |check|
152
+ object!(check, %w[check_id family target_id outcome evidence])
153
+ id!(check.fetch("check_id"))
154
+ id!(check.fetch("target_id"))
155
+ enum!(check.fetch("family"), FAMILIES + ["all"])
156
+ equal!(check.fetch("outcome"), "pass")
157
+ check.values_at("check_id", "family", "target_id")
158
+ end
159
+ unique!(actual)
160
+ equal!(actual.sort, required_checks(profile).sort)
161
+ checks.each do |check|
162
+ ref = check.fetch("evidence")
163
+ trace!(json!(ref, parent: selection.fetch("report").fetch("path")), ref, check, value)
164
+ end
165
+ end
166
+
167
+ def required_checks(profile)
168
+ checks = [["effective_policy_export", "all", "policy"], ["namespace_identity", "all", "namespace"],
169
+ ["untrusted_policy_mutation_denied", "all", "policy"]]
170
+ profile.fetch("families").each do |entry|
171
+ family = entry.fetch("family")
172
+ checks << ["unsolicited_ingress_denied", family, "ingress"]
173
+ if entry.fetch("mode") == "enabled"
174
+ %w[ordinary_internet_egress_allowed dns_response_allowed].each { |id|
175
+ checks << [id, family, id == "dns_response_allowed" ? "dns" : "internet"] }
176
+ else
177
+ checks << ["family_disabled_enforced", family, family]
178
+ end
179
+ end
180
+ profile.fetch("protected_controls").each { |entry|
181
+ checks << ["host_internal_controls_denied", entry.fetch("family"), entry.fetch("id")] }
182
+ profile.fetch("loopback_tools").each { |entry|
183
+ checks << ["loopback_scope_only", entry.fetch("family"), entry.fetch("id")] }
184
+ profile.fetch("credential_profiles").each { |entry|
185
+ checks << ["credential_no_local_control", "all", entry.fetch("id")] }
186
+ checks
187
+ end
188
+
189
+ def trace!(trace, reference, check, report)
190
+ object!(trace, TRACE_KEYS)
191
+ version!(trace)
192
+ %w[report_id boot_id namespace_identity profile_sha256 policy_export_sha256].each do |key|
193
+ equal!(trace.fetch(key), report.fetch(key))
194
+ end
195
+ %w[check_id family target_id].each { |key| equal!(trace.fetch(key), check.fetch(key)) }
196
+ equal!(trace.fetch("producer_artifact_sha256"), report.fetch("installer_artifact_sha256"))
197
+ array!(trace.fetch("observations"), min: 1, max: 256).each do |observation|
198
+ object!(observation, %w[kind artifact_refs summary])
199
+ enum!(observation.fetch("kind"), %w[policy_comparison flow namespace artifact_inspection])
200
+ object!(observation.fetch("summary"), %w[result procedure_id])
201
+ equal!(observation.fetch("summary").fetch("result"), "pass")
202
+ id!(observation.fetch("summary").fetch("procedure_id"))
203
+ refs = array!(observation.fetch("artifact_refs"), min: 1, max: 16)
204
+ refs.each { |ref| artifact!(ref, parent: reference.fetch("path")) }
205
+ end
206
+ end
207
+
208
+ def artifact!(reference, parent: nil)
209
+ object!(reference, %w[path sha256 bytes])
210
+ path = reference.fetch("path")
211
+ path!(path)
212
+ format!(reference.fetch("sha256"), SHA)
213
+ size = reference.fetch("bytes")
214
+ unless size.is_a?(Integer) && size.between?(1, ProtectedArtifactSet::LIMIT)
215
+ refuse!("network artifact byte bound is invalid")
216
+ end
217
+ previous = @refs[path]
218
+ refuse!("network artifact references conflict") if previous && previous != reference
219
+ @refs[path] = reference
220
+ @edges[parent] << path if parent
221
+ @artifacts.read!(reference)
222
+ end
223
+
224
+ def json!(reference, parent: nil)
225
+ bytes = artifact!(reference, parent: parent).dup.force_encoding(Encoding::UTF_8)
226
+ refuse!("network evidence is not UTF-8") unless bytes.valid_encoding?
227
+ JSON.parse(bytes, max_nesting: 64, allow_nan: false, create_additions: false,
228
+ allow_duplicate_key: false, allow_comments: false,
229
+ allow_invalid_escape: false, allow_control_characters: false)
230
+ end
231
+
232
+ def graph_acyclic!
233
+ visited, pending = {}, {}
234
+ visit = lambda do |path|
235
+ refuse!("network artifact reference cycle") if pending[path]
236
+ return if visited[path]
237
+ pending[path] = true
238
+ @edges[path].each { |child| visit.call(child) }
239
+ pending.delete(path)
240
+ visited[path] = true
241
+ end
242
+ @refs.each_key { |path| visit.call(path) }
243
+ end
244
+
245
+ def cidr!(value, family)
246
+ refuse!("network address range is invalid") unless value.is_a?(String) &&
247
+ (match = /\A([^\/]+)\/(0|[1-9][0-9]{0,2})\z/.match(value))
248
+ ip = IPAddr.new(value)
249
+ bits = family == "ipv4" ? 32 : 128
250
+ refuse!("network address family or canonical CIDR differs") unless
251
+ (family == "ipv4" ? ip.ipv4? : ip.ipv6?) && match[2].to_i <= bits &&
252
+ "#{ip.to_s}/#{match[2]}" == value
253
+ end
254
+
255
+ def timestamp!(value)
256
+ format!(value, /\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|\+00:00)\z/)
257
+ DateTime.rfc3339(value)
258
+ end
259
+
260
+ def identity!(value)
261
+ object!(value, %w[device inode])
262
+ unless value.values.all? { |n| n.is_a?(Integer) && n.positive? }
263
+ refuse!("network namespace identity is invalid")
264
+ end
265
+ end
266
+
267
+ def path!(value)
268
+ refuse!("network artifact path is not canonical") unless value.is_a?(String) &&
269
+ value.valid_encoding? && !value.include?("\0") && value.start_with?("/") &&
270
+ !value.split("/").any? { |part| part == "." || part == ".." } && File.expand_path(value) == value
271
+ end
272
+
273
+ def collection!(value, key, min: 0)
274
+ array!(value, min: min, max: 256)
275
+ value.each { |entry| refuse!("network collection entry is invalid") unless entry.is_a?(Hash) }
276
+ unique!(value.map { |entry| entry.fetch(key) })
277
+ value
278
+ end
279
+
280
+ def object!(value, keys)
281
+ refuse!("network evidence schema is not closed") unless value.is_a?(Hash) && value.keys.sort == keys.sort
282
+ end
283
+
284
+ def array!(value, min: 0, max: nil)
285
+ refuse!("network evidence collection is invalid") unless value.is_a?(Array) && value.size >= min &&
286
+ (max.nil? || value.size <= max)
287
+ value
288
+ end
289
+
290
+ def unique!(values)
291
+ refuse!("network evidence entries are duplicate") unless values.uniq.size == values.size
292
+ end
293
+
294
+ def version!(value) = equal!(value.fetch("version"), 1)
295
+ def id!(value) = format!(value, ID)
296
+ def format!(value, regex)
297
+ refuse!("network evidence identifier or format is invalid") unless value.is_a?(String) && regex.match?(value)
298
+ end
299
+ def enum!(value, values)
300
+ refuse!("network evidence enum is unsupported") unless values.include?(value)
301
+ end
302
+ def port!(value)
303
+ refuse!("network evidence port is invalid") unless value.is_a?(Integer) && value.between?(1, 65_535)
304
+ end
305
+ def equal!(actual, expected)
306
+ refuse!("network evidence context differs") unless same_value?(actual, expected)
307
+ end
308
+ def same_value?(actual, expected)
309
+ case expected
310
+ when Hash
311
+ actual.is_a?(Hash) && actual.keys.sort == expected.keys.sort &&
312
+ expected.all? { |key, value| same_value?(actual.fetch(key), value) }
313
+ when Array
314
+ actual.is_a?(Array) && actual.size == expected.size &&
315
+ expected.each_with_index.all? { |value, index| same_value?(actual[index], value) }
316
+ else actual.class == expected.class && actual == expected
317
+ end
318
+ end
319
+ def refuse!(message) = raise(RuntimeUnavailableError, message)
320
+
321
+ def immutable(value)
322
+ case value
323
+ when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
324
+ when Array then value.map { |item| immutable(item) }.freeze
325
+ when String then value.dup.freeze
326
+ else value.freeze
327
+ end
328
+ end
329
+ end
330
+ end
331
+ end
332
+ end
@@ -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