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,162 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require_relative "protected_artifact_set"
4
+
5
+ module Ace
6
+ module Runtime
7
+ module Molecules
8
+ # Ref-only installed discovery. Domain publication owns the projection;
9
+ # the selected child remains the original scope/admission authority.
10
+ class ProtectedTaskContextEntry
11
+ PATH = "/etc/ace/task-context-entry.json"
12
+ PRESENCE_PATHS = %w[/etc/ace/assignment-authorities.json /etc/ace/assignment-deployment-history.json /usr/local/lib/lab/herdr.installation.json].freeze
13
+ SCHEMA = "ace.protected-task-context-entry-selection/v1"
14
+ MANIFEST_SCHEMA = "lab.protected-command-entry/v1"
15
+ ROLE = "ace-assign-task-context"
16
+ METADATA_LIMIT = 65_536
17
+ FILE_LIMIT = 33_554_432
18
+ TOTAL_LIMIT = 268_435_456
19
+ COUNT_LIMIT = 4_096
20
+ Entry = Struct.new(:pin, :manifest, :body, :interpreter, keyword_init: true)
21
+
22
+ def initialize(artifacts_factory: -> { ProtectedArtifactSet.new(file_limit: FILE_LIMIT, total_limit: TOTAL_LIMIT, count_limit: COUNT_LIMIT) },
23
+ stat: File.method(:lstat))
24
+ @factory, @stat = artifacts_factory, stat
25
+ end
26
+
27
+ # Pure shape validation, never publication/original admission authority.
28
+ def validate_pin!(pin)
29
+ pin!(pin)
30
+ end
31
+
32
+ def with
33
+ unless present?(PATH)
34
+ refuse!("installed discovery is absent") if PRESENCE_PATHS.any? { |path| present?(path) }
35
+ return yield nil
36
+ end
37
+ @factory.call.with do |held|
38
+ @held = held
39
+ bytes, = held.read_path!(PATH, limit: METADATA_LIMIT)
40
+ projection = json!(bytes)
41
+ object!(projection, %w[schema task_context_entry])
42
+ refuse!("discovery schema") unless projection.fetch("schema") == SCHEMA
43
+ pin = pin!(projection.fetch("task_context_entry"))
44
+ held.verify_unchanged!
45
+ result = yield pin
46
+ held.verify_unchanged!
47
+ result
48
+ ensure
49
+ @held = nil
50
+ end
51
+ rescue KeyError, TypeError, NoMethodError, ArgumentError, IOError, SystemCallError, EncodingError
52
+ refuse!("installed discovery is unavailable")
53
+ end
54
+
55
+ # Live descriptor lifetime is this callback only. Exact protected bytes
56
+ # go to stdin; callers never execute/reopen a manifest-selected pathname.
57
+ def with_entry(pin)
58
+ refuse!("entry requires held discovery") unless @held
59
+ pin = pin!(pin)
60
+ manifest = json!(@held.read!(pin.fetch("manifest")))
61
+ object!(manifest, %w[schema role wrapper interpreter bootstrap])
62
+ unless manifest.fetch("schema") == MANIFEST_SCHEMA && manifest.fetch("role") == ROLE && manifest.fetch("wrapper") == pin.fetch("wrapper")
63
+ refuse!("selected fixed role differs")
64
+ end
65
+ reference!(manifest.fetch("interpreter"))
66
+ bootstrap = manifest.fetch("bootstrap")
67
+ object!(bootstrap, %w[original_source owner preparation startup_entries])
68
+ %w[original_source owner preparation].each do |key|
69
+ reference!(bootstrap.fetch(key))
70
+ @held.read!(bootstrap.fetch(key))
71
+ end
72
+ entries = bootstrap.fetch("startup_entries")
73
+ refuse!("bootstrap entry references") unless entries.is_a?(Hash)
74
+ reference!(entries.fetch("ace-assign"))
75
+ read_references!(entries)
76
+ body = @held.read!(pin.fetch("wrapper"))
77
+ text = body.dup.force_encoding(Encoding::UTF_8)
78
+ refuse!("wrapper source encoding") unless text.valid_encoding? && !text.include?("\0")
79
+ @held.with_readonly_handle!(manifest.fetch("interpreter")) do |interpreter|
80
+ stat = interpreter.stat
81
+ refuse!("selected interpreter is not executable") unless stat.file? && stat.executable? && (stat.mode & 0o6000).zero?
82
+ @held.verify_unchanged!
83
+ result = yield Entry.new(pin: pin, manifest: immutable(manifest), body: body, interpreter: interpreter).freeze
84
+ @held.verify_unchanged!
85
+ result
86
+ end
87
+ rescue KeyError, TypeError, NoMethodError, ArgumentError, IOError, SystemCallError, EncodingError
88
+ refuse!("selected protected entry is unavailable")
89
+ end
90
+
91
+ private
92
+
93
+ def present?(path)
94
+ @stat.call(path)
95
+ true
96
+ rescue Errno::ENOENT
97
+ false
98
+ end
99
+
100
+ def pin!(pin)
101
+ object!(pin, %w[manifest wrapper])
102
+ reference!(pin.fetch("manifest"), limit: METADATA_LIMIT)
103
+ reference!(pin.fetch("wrapper"))
104
+ immutable(pin)
105
+ end
106
+
107
+ def reference!(reference, limit: FILE_LIMIT)
108
+ object!(reference, %w[bytes path sha256])
109
+ path = reference.fetch("path")
110
+ unless path.is_a?(String) && path.encoding == Encoding::UTF_8 && path.valid_encoding? &&
111
+ path.bytesize.between?(2, 4096) && path.start_with?("/") && !path.include?("\0") && File.expand_path(path) == path &&
112
+ reference.fetch("bytes").is_a?(Integer) && reference.fetch("bytes").between?(1, limit) &&
113
+ reference.fetch("sha256").is_a?(String) && reference.fetch("sha256").match?(/\A[0-9a-f]{64}\z/)
114
+ refuse!("protected entry reference")
115
+ end
116
+ end
117
+
118
+ def read_references!(root)
119
+ pending = [root]
120
+ until pending.empty?
121
+ value = pending.pop
122
+ if value.is_a?(Hash) && value.keys.sort == %w[bytes path sha256]
123
+ reference!(value)
124
+ @held.read!(value)
125
+ elsif value.is_a?(Hash) && value.keys.all? { |key| key.is_a?(String) }
126
+ pending.concat(value.values)
127
+ elsif value.is_a?(Array)
128
+ pending.concat(value)
129
+ else
130
+ refuse!("bootstrap reference graph")
131
+ end
132
+ end
133
+ end
134
+
135
+ def json!(bytes)
136
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
137
+ refuse!("entry metadata encoding or bound") unless text.valid_encoding? && text.bytesize.between?(1, METADATA_LIMIT)
138
+ JSON.parse(text, create_additions: false, max_nesting: 32, allow_duplicate_key: false, allow_comments: false)
139
+ rescue JSON::ParserError
140
+ refuse!("entry metadata JSON")
141
+ end
142
+
143
+ def object!(value, fields)
144
+ refuse!("entry fields") unless value.is_a?(Hash) && value.keys.sort == fields.sort
145
+ end
146
+
147
+ def immutable(value)
148
+ case value
149
+ when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
150
+ when Array then value.map { |item| immutable(item) }.freeze
151
+ when String then value.dup.freeze
152
+ else value.freeze
153
+ end
154
+ end
155
+
156
+ def refuse!(detail)
157
+ raise RuntimeUnavailableError, "protected task-context entry unavailable: #{detail}"
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Runtime
5
+ module Molecules
6
+ # Structural admission of source-owned pins. Artifact authentication belongs
7
+ # to the selected entry owner; these references are never an execution grant.
8
+ module ProtectedWorkerEntry
9
+ LIMITS = {"interpreter" => 33_554_432, "wrapper" => 1_048_576}.freeze
10
+
11
+ def self.validate!(value)
12
+ unless value.is_a?(Hash) && value.keys.sort == LIMITS.keys.sort
13
+ raise ArgumentError, "worker entry fields differ"
14
+ end
15
+ LIMITS.each do |key, limit|
16
+ reference = value[key]
17
+ unless reference.is_a?(Hash) && reference.keys.sort == %w[bytes path sha256] &&
18
+ reference["bytes"].is_a?(Integer) && reference["bytes"].between?(1, limit) &&
19
+ reference["sha256"].is_a?(String) && reference["sha256"].match?(/\A[0-9a-f]{64}\z/) &&
20
+ reference["path"].is_a?(String) && reference["path"].encoding == Encoding::UTF_8 &&
21
+ reference["path"].valid_encoding? && reference["path"].bytesize.between?(2, 4096) &&
22
+ !reference["path"].include?("\0") && reference["path"].start_with?("/") &&
23
+ File.expand_path(reference["path"]) == reference["path"]
24
+ raise ArgumentError, "worker entry reference differs"
25
+ end
26
+ end
27
+ value
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require_relative "protected_artifact_set"
6
+
7
+ module Ace
8
+ module Runtime
9
+ module Molecules
10
+ # Immutable local installer input. This contains selections only; live
11
+ # process and resource observations must come from their owning readers.
12
+ class ReadinessConfiguration
13
+ LIMIT = 65_536
14
+ SCHEMA = "ace.assign.readiness/v1"
15
+ FIELDS = %w[authority boundary_manifest mapping_id native project_id runtime schema slot_id worker].freeze
16
+ attr_reader :data
17
+
18
+ def self.path(slot)
19
+ unless slot.is_a?(String) && slot.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/)
20
+ raise RuntimeUnavailableError, "invalid readiness slot"
21
+ end
22
+ "/etc/ace/execution-slots/#{slot}/readiness.json"
23
+ end
24
+
25
+ def self.decode(bytes, slot:)
26
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
27
+ unless text.bytesize.between?(1, LIMIT) && text.valid_encoding?
28
+ raise RuntimeUnavailableError, "readiness configuration is not bounded UTF-8"
29
+ end
30
+ new(JSON.parse(text, create_additions: false, max_nesting: 8,
31
+ allow_duplicate_key: false, allow_comments: false), slot: slot)
32
+ rescue JSON::ParserError, EncodingError
33
+ raise RuntimeUnavailableError, "readiness configuration is not strict JSON"
34
+ end
35
+
36
+ def self.load(slot:)
37
+ ProtectedArtifactSet.new.with do |artifacts|
38
+ bytes, = artifacts.read_path!(path(slot), limit: LIMIT)
39
+ value = decode(bytes, slot: slot)
40
+ artifacts.verify_unchanged!
41
+ value
42
+ end
43
+ end
44
+
45
+ def initialize(data, slot:)
46
+ object!(data, FIELDS)
47
+ unless data["schema"] == SCHEMA && data["slot_id"] == slot
48
+ refuse!("readiness configuration selection differs")
49
+ end
50
+ %w[mapping_id project_id].each do |key|
51
+ refuse!("invalid readiness identifier") unless data[key].is_a?(String) &&
52
+ data[key].match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/)
53
+ end
54
+ object!(data.fetch("authority"), %w[gid groups socket_path uid])
55
+ object!(data.fetch("worker"), %w[gid groups uid])
56
+ %w[authority worker].each do |kind|
57
+ principal = data.fetch(kind)
58
+ unless %w[uid gid].all? { |key| principal[key].is_a?(Integer) && principal[key].positive? } &&
59
+ principal["groups"].is_a?(Array) && principal["groups"].all? { |gid| gid.is_a?(Integer) && gid.positive? } &&
60
+ principal["groups"].sort.uniq == principal["groups"]
61
+ refuse!("readiness principal differs")
62
+ end
63
+ end
64
+ native = data.fetch("native")
65
+ object!(native, %w[executable executable_sha256 protocol socket_path version workspace_id])
66
+ unless native["protocol"] == 22 && native["version"] == "0.9.3" &&
67
+ native["workspace_id"].is_a?(String) && native["workspace_id"].match?(/\Aw[1-9][0-9]{0,8}\z/)
68
+ refuse!("readiness native protocol differs")
69
+ end
70
+ digest!(native["executable_sha256"])
71
+ boundary = data.fetch("boundary_manifest")
72
+ object!(boundary, %w[path sha256])
73
+ unless boundary["path"] == "/etc/ace/execution-slots/#{slot}/boundary-manifest.json"
74
+ refuse!("readiness boundary path differs")
75
+ end
76
+ digest!(boundary["sha256"])
77
+ [data.dig("authority", "socket_path"), native["socket_path"], native["executable"]].each { |path| path!(path) }
78
+ runtime = data.fetch("runtime")
79
+ object!(runtime, %w[dependencies interpreter_path load_paths])
80
+ path!(runtime.fetch("interpreter_path"))
81
+ paths = runtime.fetch("load_paths")
82
+ unless paths.is_a?(Array) && paths.size.between?(1, 16) && paths.uniq == paths
83
+ refuse!("readiness load paths differ")
84
+ end
85
+ paths.each { |path| path!(path) }
86
+ dependencies = runtime.fetch("dependencies")
87
+ unless dependencies.is_a?(Array) && dependencies.size.between?(1, 256) &&
88
+ dependencies.map { |ref| ref.fetch("path") }.uniq.size == dependencies.size
89
+ refuse!("readiness dependency closure differs")
90
+ end
91
+ dependencies.each do |ref|
92
+ object!(ref, %w[bytes path sha256])
93
+ path!(ref.fetch("path"))
94
+ digest!(ref.fetch("sha256"))
95
+ unless ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, ProtectedArtifactSet::LIMIT)
96
+ refuse!("readiness dependency size differs")
97
+ end
98
+ end
99
+ @data = immutable(data)
100
+ freeze
101
+ rescue KeyError, TypeError, ArgumentError
102
+ refuse!("readiness configuration fields differ")
103
+ end
104
+
105
+ def verify_selection!(mapping_id:, mapping:, authority:, installation:)
106
+ expected = {"schema" => SCHEMA, "slot_id" => mapping.fetch("execution_scope").fetch("slot_id"),
107
+ "mapping_id" => mapping_id, "project_id" => mapping.fetch("project_id"),
108
+ "authority" => authority.slice("socket_path", "uid", "gid", "groups"),
109
+ "worker" => {"uid" => mapping.fetch("worker_uid"), "gid" => mapping.fetch("worker_gid"),
110
+ "groups" => mapping.fetch("worker_groups")}, "native" => mapping.fetch("native"),
111
+ "boundary_manifest" => {"path" => "/etc/ace/execution-slots/#{data.fetch('slot_id')}/boundary-manifest.json",
112
+ "sha256" => mapping.fetch("execution_scope").fetch("boundary_manifest_sha256")}, "runtime" => data.fetch("runtime")}
113
+ refuse!("readiness configuration does not join protected deployment") unless data == expected
114
+ artifacts = installation.fetch("artifacts")
115
+ closure = data.fetch("runtime").fetch("dependencies")
116
+ unless closure.all? { |ref| artifacts.any? { |artifact|
117
+ %w[runtime_dependency readiness_executable].include?(artifact.fetch("role")) &&
118
+ artifact.fetch("view_path") == ref.fetch("path") && artifact.fetch("sha256") == ref.fetch("sha256") } } &&
119
+ artifacts.any? { |artifact| artifact.fetch("role") == "runtime_dependency" &&
120
+ artifact.fetch("view_path") == data.dig("runtime", "interpreter_path") }
121
+ refuse!("readiness dependency closure does not join installation")
122
+ end
123
+ true
124
+ end
125
+
126
+ private
127
+
128
+ def object!(value, keys)
129
+ refuse!("readiness object fields differ") unless value.is_a?(Hash) && value.keys.sort == keys.sort
130
+ end
131
+
132
+ def path!(value)
133
+ refuse!("readiness path differs") unless value.is_a?(String) && value.bytesize.between?(2, 4096) &&
134
+ value.start_with?("/") && !value.include?("\0") && File.expand_path(value) == value
135
+ end
136
+
137
+ def digest!(value)
138
+ refuse!("readiness digest differs") unless value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
139
+ end
140
+
141
+ def immutable(value)
142
+ case value
143
+ when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
144
+ when Array then value.map { |item| immutable(item) }.freeze
145
+ when String then value.dup.freeze
146
+ else value
147
+ end
148
+ end
149
+
150
+ def refuse!(message) = raise(RuntimeUnavailableError, message)
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+ require_relative "protected_artifact_set"
3
+ require "rbconfig"
4
+
5
+ module Ace
6
+ module Runtime
7
+ module Molecules
8
+ # The private hook installs this guard before loading its ACE dependencies.
9
+ # Every executable file is verified before require can evaluate it.
10
+ class ReadinessRuntime
11
+ BUILTINS = %w[enumerator.so thread.rb fiber.so rational.so complex.so ruby2_keywords.rb].freeze
12
+
13
+ def initialize(configuration:, artifacts: ProtectedArtifactSet.new)
14
+ @runtime, @artifacts = configuration.fetch("runtime"), artifacts
15
+ @references = @runtime.fetch("dependencies").to_h { |ref| [ref.fetch("path"), ref] }.freeze
16
+ end
17
+
18
+ def activate!(features: $LOADED_FEATURES, load_paths: $LOAD_PATH)
19
+ verify_loaded!(features)
20
+ @runtime.fetch("load_paths").each do |path|
21
+ ProtectedSocket.root_path!(path, directory: true)
22
+ end
23
+ load_paths.replace(@runtime.fetch("load_paths"))
24
+ @initialized_builtins ||= BUILTINS.select { |feature| features.include?(feature) }.freeze
25
+ owner = self
26
+ guard = Module.new do
27
+ define_method(:require) do |feature|
28
+ return false if owner.builtin_loaded?(feature)
29
+ path = owner.resolve!(feature)
30
+ owner.verify_file!(path)
31
+ super(path)
32
+ end
33
+ define_method(:require_relative) do |feature|
34
+ caller_file = caller_locations(1, 1).first.absolute_path
35
+ path = owner.resolve!(File.expand_path(feature, File.dirname(caller_file)))
36
+ owner.verify_file!(path)
37
+ require(path)
38
+ end
39
+ private :require, :require_relative
40
+ end
41
+ Kernel.prepend(guard)
42
+ true
43
+ end
44
+
45
+ def verify_loaded!(features)
46
+ features.each do |feature|
47
+ next if BUILTINS.include?(feature)
48
+ unless feature.is_a?(String) && feature.start_with?("/") && @references.key?(feature)
49
+ raise RuntimeUnavailableError, "readiness startup loaded undeclared code"
50
+ end
51
+ verify_file!(feature)
52
+ end
53
+ true
54
+ end
55
+
56
+ def builtin_loaded?(feature)
57
+ return false unless feature.is_a?(String) && @initialized_builtins
58
+ @initialized_builtins.any? do |builtin|
59
+ feature == builtin || feature == builtin.sub(/\.(?:rb|so)\z/, "")
60
+ end
61
+ end
62
+
63
+ def resolve!(feature)
64
+ unless feature.is_a?(String) && !feature.include?("\0")
65
+ raise RuntimeUnavailableError, "readiness require selection differs"
66
+ end
67
+ bases = feature.start_with?("/") ? [feature] : @runtime.fetch("load_paths").map { |path| File.join(path, feature) }
68
+ extension = "." + RbConfig::CONFIG.fetch("DLEXT")
69
+ candidates = bases.flat_map do |path|
70
+ # Ruby maps explicit .so/.o requests to its own platform extension.
71
+ if path.end_with?(".so", ".o")
72
+ [path.sub(/\.(?:so|o)\z/, extension)]
73
+ elsif path.end_with?(".rb", extension)
74
+ [path]
75
+ else
76
+ [path, path + ".rb", path + extension]
77
+ end
78
+ end
79
+ selected = candidates.find { |path| @references.key?(path) }
80
+ raise RuntimeUnavailableError, "readiness require is outside protected closure" unless selected
81
+ selected
82
+ end
83
+
84
+ def verify_file!(path)
85
+ @artifacts.with do |reader|
86
+ reader.read!(@references.fetch(path))
87
+ reader.verify_unchanged!
88
+ end
89
+ true
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+ require "rbconfig"
5
+ require_relative "linux_mount_info"
6
+ require_relative "../errors"
7
+
8
+ module Ace
9
+ module Runtime
10
+ module Molecules
11
+ # Observes declared reachable objects in a pinned server root. This does
12
+ # not enter or modify a namespace and carries no admission authority.
13
+ class ServerResourceObservation
14
+ VIEW_PATHS = %w[/proc /proc/sys /sys /sys/fs/cgroup /dev /dev/pts /dev/mqueue /dev/shm /tmp /var/tmp].freeze
15
+ MOUNT_FIELDS = %w[mount_id parent_id major_minor root mountpoint options filesystem_type].freeze
16
+ class Files
17
+ OPENAT2 = 437
18
+ RESOLVE = 0x10 | 0x04 # IN_ROOT | NO_SYMLINKS; bind mounts are allowed.
19
+
20
+ def supported!
21
+ unless RUBY_PLATFORM.include?("linux") && %w[x86_64 aarch64 arm64].include?(RbConfig::CONFIG.fetch("host_cpu"))
22
+ raise RuntimeUnavailableError, "contained server-root observation is unsupported"
23
+ end
24
+ end
25
+
26
+ def namespace(pid, kind = "mnt")
27
+ File.open("/proc/#{pid}/ns/#{kind}", File::RDONLY) { |file| identity(file.stat) }
28
+ end
29
+
30
+ def observe(pid, entries, authority_socket:)
31
+ supported!
32
+ root = File.open("/proc/#{pid}/root", File::RDONLY)
33
+ namespace = File.open("/proc/#{pid}/ns/mnt", File::RDONLY)
34
+ network = File.open("/proc/#{pid}/ns/net", File::RDONLY)
35
+ network_identity = network_identity!(network)
36
+ ipc = File.open("/proc/#{pid}/ns/ipc", File::RDONLY)
37
+ hook_ipc = File.open("/proc/self/ns/ipc", File::RDONLY)
38
+ root_identity, namespace_identity = identity(root.stat), identity(namespace.stat)
39
+ ipc_identity, hook_ipc_identity = identity(ipc.stat), identity(hook_ipc.stat)
40
+ table = LinuxMountInfo.new(File.read("/proc/#{pid}/mountinfo", LinuxMountInfo::LIMIT + 1))
41
+ raise RuntimeUnavailableError, "server mount inventory exceeds bound" if table.records.size > 256
42
+ topology = []
43
+ result = entries.map do |entry|
44
+ handle = open_inside(root, entry.fetch("view_path"))
45
+ begin
46
+ stat = handle.stat
47
+ raise RuntimeUnavailableError, "declared server resource is not a directory" unless stat.directory?
48
+ rows = File.read("/proc/self/fdinfo/#{handle.fileno}", 16_385).lines.filter_map do |line|
49
+ /\Amnt_id:\s+([0-9]+)\s*\z/.match(line)&.[](1)
50
+ end
51
+ raise RuntimeUnavailableError, "server resource mount is ambiguous" unless rows.size == 1
52
+ mount = table.by_id(Integer(rows.first, 10))
53
+ topology << entry.slice("host_path", "view_path").merge(mount.slice("mountpoint", "root", "major_minor", "options"))
54
+ entry.slice("host_path", "view_path").merge("device" => stat.dev, "inode" => stat.ino,
55
+ "uid" => stat.uid, "gid" => stat.gid, "filesystem_type" => mount.fetch("filesystem_type"),
56
+ "mount_id" => mount.fetch("mount_id"))
57
+ ensure
58
+ handle.close
59
+ end
60
+ end
61
+ views = VIEW_PATHS.map { |path| pinned_view(root, path, table: table) }
62
+ socket_identity = pinned_socket(root, authority_socket)
63
+ ptmx_identity, ptmx_link = pinned_ptmx(root, table: table)
64
+ repeated = LinuxMountInfo.new(File.read("/proc/#{pid}/mountinfo", LinuxMountInfo::LIMIT + 1))
65
+ unless namespace_identity == self.namespace(pid) && root_identity == identity(File.stat("/proc/#{pid}/root")) &&
66
+ network_identity == self.namespace(pid, "net") &&
67
+ ipc_identity == self.namespace(pid, "ipc") && hook_ipc_identity == self.namespace("self", "ipc") &&
68
+ table.records == repeated.records &&
69
+ views == VIEW_PATHS.map { |path| pinned_view(root, path, table: repeated) } &&
70
+ socket_identity == pinned_socket(root, authority_socket) &&
71
+ [ptmx_identity, ptmx_link] == pinned_ptmx(root, table: repeated)
72
+ raise RuntimeUnavailableError, "server root or namespace changed during observation"
73
+ end
74
+ {"mount_namespace_identity" => namespace_identity, "network_namespace_identity" => network_identity, "resource_identities" => result, "resource_topology" => topology,
75
+ "kernel_view_topology" => {"ipc_namespace_identity" => ipc_identity, "hook_ipc_namespace_identity" => hook_ipc_identity,
76
+ "mounts" => table.records.map { |row| row.slice(*MOUNT_FIELDS) }, "views" => views, "authority_socket_identity" => socket_identity,
77
+ "ptmx_identity" => ptmx_identity, "ptmx_link" => ptmx_link}}
78
+ ensure
79
+ root&.close
80
+ namespace&.close
81
+ network&.close
82
+ ipc&.close
83
+ hook_ipc&.close
84
+ end
85
+
86
+ private
87
+
88
+ # NS_GET_NSTYPE is an nsfs ioctl. Pin the original server's object,
89
+ # require CLONE_NEWNET, and retain it across the repeated path read.
90
+ def network_identity!(handle)
91
+ unless handle.ioctl(0xb703) == 0x40000000
92
+ raise RuntimeUnavailableError, "server object is not a network namespace"
93
+ end
94
+ identity(handle.stat)
95
+ rescue SystemCallError, IOError
96
+ raise RuntimeUnavailableError, "server network namespace type is unavailable"
97
+ end
98
+
99
+ def identity(stat) = {"device" => stat.dev, "inode" => stat.ino}
100
+
101
+ def pinned_view(root, path, table:)
102
+ handle = open_inside(root, path, flags: 0x200000 | File::NOFOLLOW) # O_PATH, no data access.
103
+ stat = handle.stat
104
+ raise RuntimeUnavailableError, "fixed server view is not a directory" unless stat.directory?
105
+ rows = File.read("/proc/self/fdinfo/#{handle.fileno}", 16_385).lines.filter_map { |line| /\Amnt_id:\s+([0-9]+)\s*\z/.match(line)&.[](1) }
106
+ raise RuntimeUnavailableError, "fixed view mount is ambiguous" unless rows.size == 1
107
+ mount = table.by_id(Integer(rows.first, 10))
108
+ {"path" => path, "mount_id" => mount.fetch("mount_id"), "device" => stat.dev, "inode" => stat.ino, "type" => "directory"}
109
+ ensure
110
+ handle&.close
111
+ end
112
+
113
+ def pinned_socket(root, path)
114
+ handle = open_inside(root, path, flags: 0x200000 | File::NOFOLLOW)
115
+ stat = handle.stat
116
+ raise RuntimeUnavailableError, "authority projection is not a socket" unless stat.socket?
117
+ [stat.dev, stat.ino, stat.uid]
118
+ ensure
119
+ handle&.close
120
+ end
121
+
122
+ def pinned_ptmx(root, table:)
123
+ dev = open_inside(root, "/dev", flags: 0x200000 | File::NOFOLLOW)
124
+ link = read_ptmx_link(dev)
125
+ raise RuntimeUnavailableError, "selected ptmx link differs" unless link == "pts/ptmx"
126
+ node = open_inside(root, "/dev/pts/ptmx", flags: 0x200000 | File::NOFOLLOW)
127
+ stat = node.stat
128
+ raise RuntimeUnavailableError, "selected ptmx is not a character device" unless stat.chardev? && stat.rdev_major == 5 && stat.rdev_minor == 2
129
+ rows = File.read("/proc/self/fdinfo/#{node.fileno}", 16_385).lines.filter_map { |line| /\Amnt_id:\s+([0-9]+)\s*\z/.match(line)&.[](1) }
130
+ raise RuntimeUnavailableError, "selected ptmx mount is ambiguous" unless rows.size == 1
131
+ mount = table.by_id(Integer(rows.first, 10))
132
+ [{"device" => stat.dev, "inode" => stat.ino, "mount_id" => mount.fetch("mount_id"), "type" => "character",
133
+ "rdev_major" => stat.rdev_major, "rdev_minor" => stat.rdev_minor}, link]
134
+ ensure
135
+ dev&.close
136
+ node&.close
137
+ end
138
+
139
+ def read_ptmx_link(dev)
140
+ call = Fiddle::Function.new(Fiddle::Handle::DEFAULT["readlinkat"],
141
+ [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_SSIZE_T)
142
+ buffer = Fiddle::Pointer.malloc(64)
143
+ count = call.call(dev.fileno, "ptmx\0", buffer, 64)
144
+ raise RuntimeUnavailableError, "selected ptmx link cannot be observed" unless count.between?(1, 63)
145
+ buffer.to_s(count)
146
+ end
147
+
148
+ def open_inside(root, path, flags: File::RDONLY | File::NONBLOCK | File::NOFOLLOW)
149
+ unless path.is_a?(String) && path.bytesize.between?(1, 4096) && path.start_with?("/") &&
150
+ !path.include?("\0") && File.expand_path(path) == path
151
+ raise RuntimeUnavailableError, "server resource path differs"
152
+ end
153
+ syscall = Fiddle::Function.new(Fiddle::Handle::DEFAULT["syscall"],
154
+ [Fiddle::TYPE_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T],
155
+ Fiddle::TYPE_LONG)
156
+ how = [flags, 0, RESOLVE].pack("Q<Q<Q<")
157
+ descriptor = syscall.call(OPENAT2, root.fileno, path + "\0", how, how.bytesize)
158
+ raise RuntimeUnavailableError, "contained server resource cannot be opened" if descriptor.negative?
159
+ IO.for_fd(descriptor, autoclose: true)
160
+ end
161
+ end
162
+
163
+ def initialize(kernel:, files: Files.new)
164
+ @kernel, @files = kernel, files
165
+ end
166
+
167
+ def observe!(server:, entries:, authority_socket:)
168
+ @kernel.live!(server)
169
+ before = @kernel.capture(server.fetch("pid"))
170
+ unless @kernel.same?(before, server)
171
+ raise RuntimeUnavailableError, "server incarnation differs"
172
+ end
173
+ result = @files.observe(server.fetch("pid"), entries, authority_socket: authority_socket)
174
+ @kernel.live!(server)
175
+ unless @kernel.same?(@kernel.capture(server.fetch("pid")), before)
176
+ raise RuntimeUnavailableError, "server credentials changed during resource observation"
177
+ end
178
+ result
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end