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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +9 -0
- data/docs/usage.md +123 -0
- data/lib/ace/runtime/molecules/cgroup_observation.rb +148 -0
- data/lib/ace/runtime/molecules/codex_runtime_installation.rb +130 -0
- data/lib/ace/runtime/molecules/execution_boot_baseline.rb +134 -0
- data/lib/ace/runtime/molecules/execution_network_selection.rb +85 -0
- data/lib/ace/runtime/molecules/execution_unit_installation.rb +663 -0
- data/lib/ace/runtime/molecules/kernel_view_topology.rb +133 -0
- data/lib/ace/runtime/molecules/linux_mount_info.rb +101 -0
- data/lib/ace/runtime/molecules/network_installation_evidence.rb +332 -0
- data/lib/ace/runtime/molecules/process_identity.rb +123 -0
- data/lib/ace/runtime/molecules/protected_artifact_set.rb +211 -0
- data/lib/ace/runtime/molecules/protected_linux.rb +118 -0
- data/lib/ace/runtime/molecules/protected_socket.rb +119 -0
- data/lib/ace/runtime/molecules/protected_task_context_entry.rb +162 -0
- data/lib/ace/runtime/molecules/protected_worker_entry.rb +32 -0
- data/lib/ace/runtime/molecules/readiness_configuration.rb +154 -0
- data/lib/ace/runtime/molecules/readiness_runtime.rb +94 -0
- data/lib/ace/runtime/molecules/server_resource_observation.rb +183 -0
- data/lib/ace/runtime/molecules/systemd_scope_manager.rb +411 -0
- data/lib/ace/runtime/testing/adapter_contract.rb +9 -0
- data/lib/ace/runtime/version.rb +1 -1
- data/lib/ace/runtime.rb +3 -0
- data/native/README.md +124 -0
- data/native/build-worker-gate +20 -0
- data/native/worker_gate.c +385 -0
- metadata +63 -2
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "protected_worker_entry"
|
|
4
|
+
require_relative "execution_network_selection"
|
|
5
|
+
|
|
6
|
+
require "json"
|
|
7
|
+
require "digest"
|
|
8
|
+
require "etc"
|
|
9
|
+
require_relative "systemd_scope_manager"
|
|
10
|
+
require_relative "readiness_configuration"
|
|
11
|
+
|
|
12
|
+
module Ace
|
|
13
|
+
module Runtime
|
|
14
|
+
module Molecules
|
|
15
|
+
# Root-installed artifacts plus the system manager's effective properties.
|
|
16
|
+
# These establish installation identity, not effective runtime isolation;
|
|
17
|
+
# the same-User readiness action and live owner verify that separately.
|
|
18
|
+
class ExecutionUnitInstallation
|
|
19
|
+
SCHEMA = "ace.execution-unit-manifest/v1"
|
|
20
|
+
ROLES = %w[slice_fragment service_fragment unit_dropin native_executable native_configuration
|
|
21
|
+
readiness_executable readiness_configuration boundary_manifest bootstrap worker_executable runtime_dependency].freeze
|
|
22
|
+
REQUIRED_ROLES = %w[slice_fragment service_fragment native_executable native_configuration
|
|
23
|
+
readiness_executable readiness_configuration boundary_manifest bootstrap worker_executable].freeze
|
|
24
|
+
CONTEXT_REQUIRED_ROLES = %w[slice_fragment service_fragment boundary_manifest bootstrap
|
|
25
|
+
context_executable context_configuration interpreter].freeze
|
|
26
|
+
CONTEXT_ROLES = (CONTEXT_REQUIRED_ROLES + %w[unit_dropin runtime_dependency]).freeze
|
|
27
|
+
CODEX_REQUIRED_ROLES = %w[slice_fragment service_fragment native_executable native_configuration boundary_manifest].freeze
|
|
28
|
+
CODEX_ROLES = (CODEX_REQUIRED_ROLES + %w[unit_dropin runtime_dependency]).freeze
|
|
29
|
+
SERVICE_REQUIRED = {"Type" => "exec", "Restart" => "no", "RestartForceExitStatus" => [[], []],
|
|
30
|
+
"KillMode" => "control-group", "SendSIGKILL" => true, "Delegate" => false,
|
|
31
|
+
"ProtectControlGroups" => true, "NoNewPrivileges" => true, "CapabilityBoundingSet" => 0,
|
|
32
|
+
"AmbientCapabilities" => 0, "RestrictNamespaces" => 0, "PrivateIPC" => true,
|
|
33
|
+
"PrivateDevices" => true, "PrivateTmp" => false, "MountAPIVFS" => true, "ProtectKernelTunables" => true, "BindLogSockets" => false,
|
|
34
|
+
"DevicePolicy" => "closed", "DeviceAllow" => [], "ProtectSystem" => "strict", "DynamicUser" => false, "PAMName" => "",
|
|
35
|
+
"EnvironmentFiles" => [], "PassEnvironment" => [], "UnsetEnvironment" => [],
|
|
36
|
+
"StandardInput" => "null", "TTYPath" => "", "StandardOutput" => "journal", "StandardError" => "journal", "RuntimeDirectoryPreserve" => "no",
|
|
37
|
+
"RuntimeDirectoryMode" => 0o700, "UMask" => 0o077, "RootImage" => "", "RootImageOptions" => [], "RootEphemeral" => false, "ExtensionDirectories" => [],
|
|
38
|
+
"ExtensionImages" => [], "MountImages" => [], "TemporaryFileSystem" => [], "InaccessiblePaths" => []}.freeze
|
|
39
|
+
EMPTY_ACTIVATION = %w[Requisite BindsTo PartOf Upholds OnFailure OnSuccess OnFailureOf OnSuccessOf
|
|
40
|
+
TriggeredBy Triggers PropagatesStopTo StopPropagatedFrom JoinsNamespaceOf].freeze
|
|
41
|
+
NATIVE_ENVIRONMENT = %w[HERDR_CONFIG_PATH HERDR_SOCKET_PATH HOME SHELL PATH LANG LC_ALL TERM
|
|
42
|
+
TMPDIR TMP TEMP XDG_CONFIG_HOME XDG_DATA_HOME XDG_STATE_HOME XDG_CACHE_HOME XDG_RUNTIME_DIR CODEX_HOME CLAUDE_CONFIG_DIR].freeze
|
|
43
|
+
MANAGER_ENVIRONMENT = (NATIVE_ENVIRONMENT + %w[LANGUAGE LC_CTYPE LC_NUMERIC LC_TIME LC_COLLATE LC_MONETARY LC_MESSAGES
|
|
44
|
+
LC_PAPER LC_NAME LC_ADDRESS LC_TELEPHONE LC_MEASUREMENT LC_IDENTIFICATION]).freeze
|
|
45
|
+
IMPLICIT_NAMESPACE_ROOTS = %w[/dev /proc /sys /run /tmp /var/tmp].freeze
|
|
46
|
+
EMPTY_EXEC = %w[ExecConditionEx ExecStartPreEx ExecReloadEx ExecStopEx ExecStopPostEx].freeze
|
|
47
|
+
|
|
48
|
+
class Files
|
|
49
|
+
def authority_socket!(authority)
|
|
50
|
+
path = authority.fetch("socket_path")
|
|
51
|
+
ProtectedSocket.root_path!(File.dirname(path), directory: true, owner: authority.fetch("uid"))
|
|
52
|
+
identity = ProtectedSocket.socket_identity(path)
|
|
53
|
+
raise RuntimeUnavailableError, "selected authority socket owner differs" unless identity.last == authority.fetch("uid")
|
|
54
|
+
identity
|
|
55
|
+
end
|
|
56
|
+
def read(path, limit:)
|
|
57
|
+
ProtectedSocket.root_path!(path)
|
|
58
|
+
File.open(path, File::RDONLY | File::NONBLOCK | File::NOFOLLOW) do |file|
|
|
59
|
+
stat = file.stat
|
|
60
|
+
unless stat.file? && stat.uid.zero? && (stat.mode & 0o6022).zero? && stat.size <= limit
|
|
61
|
+
raise RuntimeUnavailableError, "installed artifact descriptor ownership is unsafe"
|
|
62
|
+
end
|
|
63
|
+
bytes = file.read(limit + 1)
|
|
64
|
+
raise RuntimeUnavailableError, "installed artifact is oversized" if bytes.bytesize > limit
|
|
65
|
+
after = file.stat
|
|
66
|
+
unless [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode, stat.size, stat.mtime, stat.ctime] ==
|
|
67
|
+
[after.dev, after.ino, after.uid, after.gid, after.mode, after.size, after.mtime, after.ctime] && bytes.bytesize == stat.size
|
|
68
|
+
raise RuntimeUnavailableError, "installed held artifact changed"
|
|
69
|
+
end
|
|
70
|
+
bytes
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def activation_routes(paths, service_unit)
|
|
75
|
+
routes = []
|
|
76
|
+
visited = 0
|
|
77
|
+
pending = paths.map { |path| [path, 0] }
|
|
78
|
+
until pending.empty?
|
|
79
|
+
directory, depth = pending.shift
|
|
80
|
+
begin
|
|
81
|
+
ProtectedSocket.root_path!(directory, directory: true)
|
|
82
|
+
rescue RuntimeUnavailableError
|
|
83
|
+
if !File.exist?(directory) && !File.symlink?(directory)
|
|
84
|
+
ancestor = File.dirname(directory)
|
|
85
|
+
ancestor = File.dirname(ancestor) until File.exist?(ancestor) || ancestor == "/"
|
|
86
|
+
ProtectedSocket.root_path!(ancestor, directory: true)
|
|
87
|
+
next
|
|
88
|
+
end
|
|
89
|
+
raise
|
|
90
|
+
end
|
|
91
|
+
Dir.children(directory).sort.each do |name|
|
|
92
|
+
visited += 1
|
|
93
|
+
raise RuntimeUnavailableError, "installed activation graph is oversized" if visited > 50_000
|
|
94
|
+
path = File.join(directory, name)
|
|
95
|
+
stat = File.lstat(path)
|
|
96
|
+
if stat.symlink?
|
|
97
|
+
raise RuntimeUnavailableError, "activation graph link owner differs" unless stat.uid.zero?
|
|
98
|
+
target = File.expand_path(File.readlink(path), directory)
|
|
99
|
+
if File.basename(target) == service_unit ||
|
|
100
|
+
(name == service_unit && directory.match?(/\.(?:wants|requires|upholds)\z/))
|
|
101
|
+
routes << path
|
|
102
|
+
end
|
|
103
|
+
elsif stat.directory?
|
|
104
|
+
raise RuntimeUnavailableError, "activation graph directory is too deep" if depth >= 8
|
|
105
|
+
pending << [path, depth + 1]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
routes
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def sha256(path)
|
|
113
|
+
ProtectedSocket.root_path!(path)
|
|
114
|
+
File.open(path, File::RDONLY | File::NOFOLLOW) do |file|
|
|
115
|
+
stat = file.stat
|
|
116
|
+
unless stat.file? && stat.uid.zero? && (stat.mode & 0o6022).zero? && stat.size <= 268_435_456
|
|
117
|
+
raise RuntimeUnavailableError, "installed artifact kind/mode/size is unsafe"
|
|
118
|
+
end
|
|
119
|
+
digest = Digest::SHA256.new
|
|
120
|
+
while (chunk = file.read(65_536))
|
|
121
|
+
digest.update(chunk)
|
|
122
|
+
end
|
|
123
|
+
digest.hexdigest
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def initialize(scope:, native:, bootstrap:, worker_entry:, worker_uid:, worker_gid:, files: Files.new)
|
|
129
|
+
@scope, @native, @bootstrap, @worker_entry, @files = scope, native, bootstrap, worker_entry, files
|
|
130
|
+
ProtectedWorkerEntry.validate!(@worker_entry)
|
|
131
|
+
@worker_uid, @worker_gid = worker_uid, worker_gid
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Context entries have their own fixed protocol. The same effective
|
|
135
|
+
# installation, activation, mount and isolation checks still apply.
|
|
136
|
+
def self.for_inbox_context(service:, configuration:, load_paths:, authority:, owner_credentials:, files: Files.new)
|
|
137
|
+
unless owner_credentials.is_a?(Hash) && owner_credentials.keys.sort == %w[gid groups uid] &&
|
|
138
|
+
owner_credentials.values_at("uid", "gid").all? { |id| id.is_a?(Integer) && id.positive? } &&
|
|
139
|
+
owner_credentials["groups"].is_a?(Array) && owner_credentials["groups"].size <= 64 &&
|
|
140
|
+
owner_credentials["groups"].all? { |id| id.is_a?(Integer) && id >= 0 } &&
|
|
141
|
+
owner_credentials["groups"] == owner_credentials["groups"].sort.uniq &&
|
|
142
|
+
load_paths.is_a?(Array) && load_paths.size.between?(1, 64) && load_paths.uniq.size == load_paths.size
|
|
143
|
+
raise RuntimeUnavailableError, "context installation selection differs"
|
|
144
|
+
end
|
|
145
|
+
selected = allocate
|
|
146
|
+
selected.send(:initialize_context, service, configuration, load_paths, authority, owner_credentials, files)
|
|
147
|
+
selected
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Selection only. The original bootstrap authenticates mapping ownership;
|
|
151
|
+
# verify! still requires the actual fixed manager profile and full topology.
|
|
152
|
+
def verify_codex_runtime_selection!(service:, intent_reference:, manager:)
|
|
153
|
+
unless @codex_service && @codex_service == service && @codex_intent_reference == intent_reference
|
|
154
|
+
raise RuntimeUnavailableError, "Codex dedicated installation selection differs"
|
|
155
|
+
end
|
|
156
|
+
verify!(manager: manager)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def self.for_codex_runtime(service:, intent_reference:, original_mapping:, mapping_id:, authority:, files: Files.new)
|
|
160
|
+
require_relative "codex_runtime_installation"
|
|
161
|
+
selected = allocate
|
|
162
|
+
selected.send(:initialize_codex_runtime, service, intent_reference, original_mapping, mapping_id, authority, files)
|
|
163
|
+
selected
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Descriptor shape only; no effective unit observation or role grant.
|
|
167
|
+
def self.validate_inbox_context_service!(service)
|
|
168
|
+
selected = allocate
|
|
169
|
+
selected.send(:validate_context_service!, service)
|
|
170
|
+
true
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def verify!(manager:)
|
|
174
|
+
slot = @scope.fetch("slot_id")
|
|
175
|
+
unless slot.is_a?(String) && SystemdScopeManager::UNIT.match?(slot)
|
|
176
|
+
raise RuntimeUnavailableError, "installed slot identity is invalid"
|
|
177
|
+
end
|
|
178
|
+
selected_service = @context || @codex_service
|
|
179
|
+
path = selected_service ? selected_service.fetch("unit_manifest").fetch("path") : "/etc/ace/execution-slots/#{slot}/unit-manifest.json"
|
|
180
|
+
bytes = @files.read(path, limit: 65_536)
|
|
181
|
+
unless Digest::SHA256.hexdigest(bytes) == @scope.fetch("unit_manifest_sha256") &&
|
|
182
|
+
(!selected_service || bytes.bytesize == selected_service.fetch("unit_manifest").fetch("bytes"))
|
|
183
|
+
raise RuntimeUnavailableError, "unit manifest bytes differ from deployment"
|
|
184
|
+
end
|
|
185
|
+
manifest = JSON.parse(bytes)
|
|
186
|
+
unless manifest.is_a?(Hash) && manifest.keys.sort == %w[artifacts properties schema slot_id] &&
|
|
187
|
+
manifest["schema"] == SCHEMA && manifest["slot_id"] == slot
|
|
188
|
+
raise RuntimeUnavailableError, "unit manifest schema/slot differs"
|
|
189
|
+
end
|
|
190
|
+
artifacts = verify_artifacts!(manifest.fetch("artifacts"))
|
|
191
|
+
profile = @context ? manager.inspect_inbox_context_profile : manager.inspect_profile
|
|
192
|
+
verify_profile!(profile, manifest.fetch("properties"), artifacts)
|
|
193
|
+
manifest
|
|
194
|
+
rescue SystemCallError, IOError, JSON::ParserError, KeyError, TypeError, NoMethodError
|
|
195
|
+
raise RuntimeUnavailableError, "installed execution units cannot be verified"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Source validity only. The caller owns selected staged bytes; this
|
|
199
|
+
# neither observes a live authority nor grants installed readiness.
|
|
200
|
+
def validate_staged_boundary_topology!(service:, artifacts:, authority:)
|
|
201
|
+
validate_boundary_topology!(service, artifacts, authority: authority)
|
|
202
|
+
true
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def inbox_context_scope
|
|
206
|
+
raise RuntimeUnavailableError, "installation is not a context service" unless @context
|
|
207
|
+
@scope
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def inbox_context_configuration_reference
|
|
211
|
+
raise RuntimeUnavailableError, "installation is not a context service" unless @context
|
|
212
|
+
@context_configuration
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def inbox_context_service
|
|
216
|
+
raise RuntimeUnavailableError, "installation is not a context service" unless @context
|
|
217
|
+
@context
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Called from the held context configuration before service admission.
|
|
221
|
+
# Native startup bytes and IPC paths must be actual members of this
|
|
222
|
+
# SAME complete installed profile, not merely readable host selections.
|
|
223
|
+
def verify_inbox_native_projection!(references:, resources:, manager:)
|
|
224
|
+
raise RuntimeUnavailableError, "installation is not a context service" unless @context
|
|
225
|
+
manifest = verify!(manager: manager)
|
|
226
|
+
artifacts = manifest.fetch("artifacts")
|
|
227
|
+
profile = manager.inspect_inbox_context_profile.fetch("service")
|
|
228
|
+
unless references.is_a?(Array) && references.size.between?(3, 512) &&
|
|
229
|
+
resources.is_a?(Array) && resources.size <= 64
|
|
230
|
+
raise RuntimeUnavailableError, "context native projection exceeds bounds"
|
|
231
|
+
end
|
|
232
|
+
references.each do |ref|
|
|
233
|
+
unless ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] &&
|
|
234
|
+
ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 268_435_456) &&
|
|
235
|
+
artifacts.any? { |artifact| artifact.values_at("host_path", "view_path", "sha256", "role") ==
|
|
236
|
+
[ref.fetch("path"), ref.fetch("path"), ref.fetch("sha256"), "runtime_dependency"] }
|
|
237
|
+
raise RuntimeUnavailableError, "context native startup reference is not exactly projected"
|
|
238
|
+
end
|
|
239
|
+
bytes = @files.read(ref.fetch("path"), limit: ref.fetch("bytes"))
|
|
240
|
+
unless bytes.bytesize == ref.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == ref.fetch("sha256")
|
|
241
|
+
raise RuntimeUnavailableError, "context native startup reference differs"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
boundary = JSON.parse(@files.read(@context.fetch("boundary_manifest").fetch("path"), limit: 65_536))
|
|
245
|
+
resources.each do |resource|
|
|
246
|
+
unless resource.is_a?(Hash) && resource.keys.sort == %w[access gid kind mode path uid] &&
|
|
247
|
+
%w[read write].include?(resource["access"]) && path?(resource["path"])
|
|
248
|
+
raise RuntimeUnavailableError, "context native resource fields differ"
|
|
249
|
+
end
|
|
250
|
+
path = resource.fetch("path")
|
|
251
|
+
readonly = resource.fetch("access") == "read"
|
|
252
|
+
mounts = profile.fetch(readonly ? "BindReadOnlyPaths" : "BindPaths")
|
|
253
|
+
unless mounts.include?([path, path, false, 0]) &&
|
|
254
|
+
boundary.fetch("resources").any? { |entry| entry.values_at("host_path", "view_path", "stage", "worker_visible", "read_only") ==
|
|
255
|
+
[path, path, "parent", true, readonly] } &&
|
|
256
|
+
(!readonly || profile.fetch("BindPaths").none? { |mount| overlaps?(path, mount[1]) } &&
|
|
257
|
+
profile.fetch("ReadWritePaths").none? { |root| overlaps?(path, root) })
|
|
258
|
+
raise RuntimeUnavailableError, "context native resource is not exactly projected"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
verify!(manager: manager)
|
|
262
|
+
true
|
|
263
|
+
rescue SystemCallError, IOError, JSON::ParserError, KeyError, TypeError, NoMethodError
|
|
264
|
+
raise RuntimeUnavailableError, "context native projection is unavailable"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
private
|
|
268
|
+
|
|
269
|
+
def initialize_context(service, configuration, load_paths, authority, credentials, files)
|
|
270
|
+
validate_context_service!(service)
|
|
271
|
+
refs = service.slice("unit_manifest", "boundary_manifest", "entry", "interpreter", "bootstrap_manifest").merge("configuration" => configuration)
|
|
272
|
+
unless refs.values.all? { |ref| ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] && path?(ref["path"]) &&
|
|
273
|
+
ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 268_435_456) &&
|
|
274
|
+
ref["sha256"].is_a?(String) && ref["sha256"].match?(/\A[0-9a-f]{64}\z/) } &&
|
|
275
|
+
load_paths.all? { |path| path?(path) }
|
|
276
|
+
raise RuntimeUnavailableError, "context installation artifacts differ"
|
|
277
|
+
end
|
|
278
|
+
@scope = immutable_selection(service.fetch("execution_scope"))
|
|
279
|
+
@context = immutable_selection(service)
|
|
280
|
+
@context_configuration, @context_authority = immutable_selection(configuration), immutable_selection(authority)
|
|
281
|
+
@context_load_paths, @context_groups = immutable_selection(load_paths), immutable_selection(credentials.fetch("groups"))
|
|
282
|
+
@worker_uid, @worker_gid, @files = credentials.fetch("uid"), credentials.fetch("gid"), files
|
|
283
|
+
rescue KeyError, TypeError
|
|
284
|
+
raise RuntimeUnavailableError, "context installation selection is incomplete"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def validate_context_service!(service)
|
|
288
|
+
unless service.is_a?(Hash) && service.keys.sort == %w[bootstrap_manifest boundary_manifest entry execution_scope interpreter unit_manifest] &&
|
|
289
|
+
service.reject { |key, _| key == "execution_scope" }.values.all? { |ref|
|
|
290
|
+
ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] && path?(ref["path"]) &&
|
|
291
|
+
ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 268_435_456) &&
|
|
292
|
+
ref["sha256"].is_a?(String) && ref["sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
293
|
+
}
|
|
294
|
+
raise RuntimeUnavailableError, "context service descriptor differs"
|
|
295
|
+
end
|
|
296
|
+
scope = service.fetch("execution_scope")
|
|
297
|
+
unless scope.is_a?(Hash) && scope.keys.sort == %w[backend boundary_manifest_sha256 network_namespace_path root_directory runtime_directory service_unit slice_unit slot_id unit_manifest_sha256] &&
|
|
298
|
+
scope["backend"] == "linux_systemd_cgroup_v2" && scope["slot_id"].is_a?(String) && SystemdScopeManager::UNIT.match?(scope["slot_id"]) &&
|
|
299
|
+
%w[root_directory runtime_directory network_namespace_path].all? { |key| path?(scope[key]) } &&
|
|
300
|
+
scope.fetch("runtime_directory").start_with?("/run/") && !overlaps?(scope.fetch("root_directory"), scope.fetch("runtime_directory")) &&
|
|
301
|
+
{"slice_unit" => ".slice", "service_unit" => ".service"}.all? { |key, suffix|
|
|
302
|
+
scope[key].is_a?(String) && SystemdScopeManager::UNIT.match?(scope[key]) && scope[key].end_with?(suffix) && !scope[key].downcase.include?("overseer")
|
|
303
|
+
} &&
|
|
304
|
+
scope["unit_manifest_sha256"] == service.fetch("unit_manifest").fetch("sha256") &&
|
|
305
|
+
scope["boundary_manifest_sha256"] == service.fetch("boundary_manifest").fetch("sha256") &&
|
|
306
|
+
service.fetch("unit_manifest").fetch("bytes") <= 65_536 && service.fetch("boundary_manifest").fetch("bytes") <= 65_536
|
|
307
|
+
raise RuntimeUnavailableError, "context service scope differs"
|
|
308
|
+
end
|
|
309
|
+
rescue KeyError, TypeError, NoMethodError
|
|
310
|
+
raise RuntimeUnavailableError, "context service descriptor is incomplete"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def immutable_selection(value)
|
|
314
|
+
case value
|
|
315
|
+
when Hash then value.to_h { |key, item| [key.dup.freeze, immutable_selection(item)] }.freeze
|
|
316
|
+
when Array then value.map { |item| immutable_selection(item) }.freeze
|
|
317
|
+
when String then value.dup.freeze
|
|
318
|
+
else value.freeze
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def verify_artifacts!(artifacts)
|
|
323
|
+
minimum = @context ? CONTEXT_REQUIRED_ROLES.size : @codex_service ? CODEX_REQUIRED_ROLES.size : 9
|
|
324
|
+
unless artifacts.is_a?(Array) && artifacts.size.between?(minimum, 512) &&
|
|
325
|
+
artifacts.all? { |a| a.is_a?(Hash) && a.keys.sort == %w[host_path role sha256 view_path] } &&
|
|
326
|
+
artifacts.map { |a| a["host_path"] }.uniq.size == artifacts.size
|
|
327
|
+
raise RuntimeUnavailableError, "installed unit artifact declarations differ"
|
|
328
|
+
end
|
|
329
|
+
roles = artifacts.group_by { |a| a.fetch("role") }
|
|
330
|
+
required, allowed = if @context
|
|
331
|
+
[CONTEXT_REQUIRED_ROLES, CONTEXT_ROLES]
|
|
332
|
+
elsif @codex_service
|
|
333
|
+
[CODEX_REQUIRED_ROLES, CODEX_ROLES]
|
|
334
|
+
else
|
|
335
|
+
[REQUIRED_ROLES, ROLES]
|
|
336
|
+
end
|
|
337
|
+
unless required.all? { |role| roles.key?(role) } && (roles.keys - allowed).empty? &&
|
|
338
|
+
required.all? { |role| roles[role].size == 1 }
|
|
339
|
+
raise RuntimeUnavailableError, "installed unit artifacts are incomplete or ambiguous"
|
|
340
|
+
end
|
|
341
|
+
artifacts.each do |artifact|
|
|
342
|
+
path, digest = artifact.values_at("host_path", "sha256")
|
|
343
|
+
unless path?(path) && path?(artifact["view_path"]) && digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/) && @files.sha256(path) == digest
|
|
344
|
+
raise RuntimeUnavailableError, "installed unit artifact identity changed"
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
return verify_context_artifacts!(roles) if @context
|
|
348
|
+
return verify_codex_artifacts!(roles) if @codex_service
|
|
349
|
+
expected = {"native_executable" => @native.fetch("executable"), "bootstrap" => @bootstrap,
|
|
350
|
+
"worker_executable" => @worker_entry.fetch("wrapper").fetch("path"),
|
|
351
|
+
"readiness_configuration" => "/etc/ace/execution-slots/#{@scope.fetch('slot_id')}/readiness.json",
|
|
352
|
+
"boundary_manifest" => "/etc/ace/execution-slots/#{@scope.fetch('slot_id')}/boundary-manifest.json"}
|
|
353
|
+
unless expected.all? { |role, path| roles.fetch(role).first.fetch("view_path") == path } &&
|
|
354
|
+
roles.fetch("native_executable").first.fetch("sha256") == @native.fetch("executable_sha256") &&
|
|
355
|
+
roles.fetch("boundary_manifest").first.fetch("sha256") == @scope.fetch("boundary_manifest_sha256")
|
|
356
|
+
raise RuntimeUnavailableError, "installed executable declarations differ from deployment"
|
|
357
|
+
end
|
|
358
|
+
{"wrapper" => "worker_executable", "interpreter" => "runtime_dependency"}.each do |key, role|
|
|
359
|
+
reference = @worker_entry.fetch(key)
|
|
360
|
+
matches = roles.fetch(role).select { |artifact| artifact.fetch("view_path") == reference.fetch("path") }
|
|
361
|
+
unless matches.size == 1 && matches.first.fetch("sha256") == reference.fetch("sha256")
|
|
362
|
+
raise RuntimeUnavailableError, "installed worker entry role differs"
|
|
363
|
+
end
|
|
364
|
+
bytes = @files.read(matches.first.fetch("host_path"), limit: reference.fetch("bytes"))
|
|
365
|
+
unless bytes.bytesize == reference.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == reference.fetch("sha256")
|
|
366
|
+
raise RuntimeUnavailableError, "installed worker entry bytes differ"
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
roles
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def verify_context_artifacts!(roles)
|
|
373
|
+
refs = {"context_executable" => @context.fetch("entry"), "interpreter" => @context.fetch("interpreter"),
|
|
374
|
+
"bootstrap" => @context.fetch("bootstrap_manifest"), "boundary_manifest" => @context.fetch("boundary_manifest"),
|
|
375
|
+
"context_configuration" => @context_configuration}
|
|
376
|
+
refs.each do |role, ref|
|
|
377
|
+
artifact = roles.fetch(role).first
|
|
378
|
+
bytes = @files.read(artifact.fetch("host_path"), limit: ref.fetch("bytes"))
|
|
379
|
+
unless artifact.fetch("host_path") == ref.fetch("path") && artifact.fetch("sha256") == ref.fetch("sha256") &&
|
|
380
|
+
(!%w[bootstrap boundary_manifest context_configuration].include?(role) || artifact.fetch("view_path") == ref.fetch("path")) &&
|
|
381
|
+
bytes.bytesize == ref.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == ref.fetch("sha256")
|
|
382
|
+
raise RuntimeUnavailableError, "context host artifact differs from selected immutable reference"
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
roles
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def verify_profile!(profile, expected, artifacts)
|
|
389
|
+
unless expected.is_a?(Hash) && expected.keys.sort == %w[service slice]
|
|
390
|
+
raise RuntimeUnavailableError, "unit profile declarations differ"
|
|
391
|
+
end
|
|
392
|
+
environment = profile.fetch("manager_environment")
|
|
393
|
+
unless environment.is_a?(Array) && environment.all? { |entry| entry.is_a?(String) &&
|
|
394
|
+
MANAGER_ENVIRONMENT.include?(entry.split("=", 2).first) }
|
|
395
|
+
raise RuntimeUnavailableError, "effective manager environment changes the fixed startup surface"
|
|
396
|
+
end
|
|
397
|
+
{"slice" => SystemdScopeManager::UNIT_GRAPH_SIGNATURES.keys,
|
|
398
|
+
"service" => (SystemdScopeManager::UNIT_GRAPH_SIGNATURES.keys + SystemdScopeManager::SERVICE_EXEC_SIGNATURES.keys + (@context ? ["TimeoutStopUSec"] : [])).uniq}.each do |kind, keys|
|
|
399
|
+
wanted, actual = expected.fetch(kind), profile.fetch(kind)
|
|
400
|
+
projected = actual.dup
|
|
401
|
+
%w[ExecStartEx ExecStartPostEx].each do |key|
|
|
402
|
+
projected[key] = actual.fetch(key).map { |command| command.first(3) } if kind == "service"
|
|
403
|
+
end
|
|
404
|
+
unless wanted.is_a?(Hash) && wanted.keys.sort == keys.sort && projected == wanted &&
|
|
405
|
+
actual["Id"] == @scope.fetch("#{kind}_unit") && actual["Names"] == [actual["Id"]] &&
|
|
406
|
+
actual["LoadState"] == "loaded" && actual["NeedDaemonReload"] == false
|
|
407
|
+
raise RuntimeUnavailableError, "effective installed unit properties differ"
|
|
408
|
+
end
|
|
409
|
+
fragment = artifacts.fetch("#{kind}_fragment").first.fetch("host_path")
|
|
410
|
+
unless actual["FragmentPath"] == fragment && File.basename(fragment) == @scope.fetch("#{kind}_unit")
|
|
411
|
+
raise RuntimeUnavailableError, "system manager loaded another unit fragment"
|
|
412
|
+
end
|
|
413
|
+
unless actual.fetch("DropInPaths").is_a?(Array) && actual.fetch("DropInPaths").all? { |path|
|
|
414
|
+
artifacts.fetch("unit_dropin", []).any? { |artifact| artifact["host_path"] == path }
|
|
415
|
+
}
|
|
416
|
+
raise RuntimeUnavailableError, "system manager loaded an unverified drop-in"
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
slice, service = profile.values_at("slice", "service")
|
|
420
|
+
verify_graph!(slice, service, profile.fetch("ancestors"), profile.fetch("prerequisites"))
|
|
421
|
+
unless @files.activation_routes(profile.fetch("unit_paths"), @scope.fetch("service_unit")).empty?
|
|
422
|
+
raise RuntimeUnavailableError, "service has installed outside enablement or alias routes"
|
|
423
|
+
end
|
|
424
|
+
unless slice["StopWhenUnneeded"] == false && service["Sockets"] == [] &&
|
|
425
|
+
service["RootDirectory"] == @scope.fetch("root_directory") &&
|
|
426
|
+
service["NetworkNamespacePath"] == @scope.fetch("network_namespace_path") &&
|
|
427
|
+
credential_id(service.fetch("User"), :uid) == @worker_uid &&
|
|
428
|
+
credential_id(service.fetch("Group"), :gid) == @worker_gid &&
|
|
429
|
+
SERVICE_REQUIRED.all? { |key, value| service[key] == value } &&
|
|
430
|
+
service["SupplementaryGroups"] == (@context ? @context_groups.map(&:to_s) : @codex_service ? @codex_groups.map(&:to_s) : []) &&
|
|
431
|
+
service["RestrictAddressFamilies"] == [true, %w[AF_INET AF_INET6 AF_UNIX]] &&
|
|
432
|
+
EMPTY_EXEC.all? { |key| service[key] == [] }
|
|
433
|
+
raise RuntimeUnavailableError, "execution unit lacks the required retained isolation profile"
|
|
434
|
+
end
|
|
435
|
+
if @context
|
|
436
|
+
verify_context_protocol!(service, artifacts)
|
|
437
|
+
verify_artifact_projection!(service, artifacts)
|
|
438
|
+
verify_boundary_topology!(service, artifacts, authority: @context_authority)
|
|
439
|
+
return
|
|
440
|
+
end
|
|
441
|
+
if @codex_service
|
|
442
|
+
verify_codex_protocol!(service, artifacts)
|
|
443
|
+
verify_artifact_projection!(service, artifacts)
|
|
444
|
+
verify_boundary_topology!(service, artifacts, authority: @codex_authority)
|
|
445
|
+
return
|
|
446
|
+
end
|
|
447
|
+
verify_command!(service.fetch("ExecStartEx"), artifacts.fetch("native_executable").first.fetch("view_path"))
|
|
448
|
+
config_artifact = artifacts.fetch("readiness_configuration").first
|
|
449
|
+
config = ReadinessConfiguration.decode(@files.read(config_artifact.fetch("host_path"), limit: 65_536),
|
|
450
|
+
slot: @scope.fetch("slot_id"))
|
|
451
|
+
interpreter = config.data.dig("runtime", "interpreter_path")
|
|
452
|
+
unless artifacts.fetch("runtime_dependency", []).any? { |artifact| artifact.fetch("view_path") == interpreter }
|
|
453
|
+
raise RuntimeUnavailableError, "readiness interpreter is not installed"
|
|
454
|
+
end
|
|
455
|
+
verify_command!(service.fetch("ExecStartPostEx"), interpreter)
|
|
456
|
+
unless service.fetch("ExecStartEx").first[1] == [@native.fetch("executable"), "server"] &&
|
|
457
|
+
service.fetch("ExecStartPostEx").first[1] == [interpreter, "--disable=gems,rubyopt",
|
|
458
|
+
artifacts.fetch("readiness_executable").first.fetch("view_path"), @scope.fetch("slot_id")]
|
|
459
|
+
raise RuntimeUnavailableError, "unit commands differ from the fixed native/readiness protocol"
|
|
460
|
+
end
|
|
461
|
+
environment = service.fetch("Environment")
|
|
462
|
+
unless environment.is_a?(Array) && environment.all? { |entry| entry.is_a?(String) && !entry.include?("\0") } &&
|
|
463
|
+
environment.all? { |entry| key, value = entry.split("=", 2); NATIVE_ENVIRONMENT.include?(key) && value && !value.empty? } &&
|
|
464
|
+
environment.map { |entry| entry.split("=", 2).first }.uniq.size == environment.size &&
|
|
465
|
+
environment.include?("HERDR_CONFIG_PATH=#{artifacts.fetch('native_configuration').first.fetch('view_path')}")
|
|
466
|
+
raise RuntimeUnavailableError, "native configuration is not the exact immutable input"
|
|
467
|
+
end
|
|
468
|
+
verify_artifact_projection!(service, artifacts)
|
|
469
|
+
verify_boundary_topology!(service, artifacts, authority: config.data.fetch("authority"))
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def verify_context_protocol!(service, artifacts)
|
|
473
|
+
interpreter = artifacts.fetch("interpreter").first.fetch("view_path")
|
|
474
|
+
entry = artifacts.fetch("context_executable").first.fetch("view_path")
|
|
475
|
+
verify_command!(service.fetch("ExecStartEx"), interpreter)
|
|
476
|
+
arguments = [interpreter, "--disable=gems,rubyopt", *@context_load_paths.flat_map { |path| ["-I", path] }, entry]
|
|
477
|
+
unless service.fetch("ExecStartEx").first[1] == arguments && service.fetch("ExecStartPostEx") == [] &&
|
|
478
|
+
service.fetch("Environment") == [] && service.fetch("TimeoutStopUSec") == 35_000_000 &&
|
|
479
|
+
@context_load_paths.all? { |path| artifacts.fetch("runtime_dependency", []).any? { |artifact| covers?(path, artifact.fetch("view_path")) } }
|
|
480
|
+
raise RuntimeUnavailableError, "context commands differ from the fixed service protocol"
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def verify_graph!(slice, service, ancestors, prerequisites)
|
|
485
|
+
unless ancestors.is_a?(Hash) && !ancestors.empty?
|
|
486
|
+
raise RuntimeUnavailableError, "parent activation graph is incomplete"
|
|
487
|
+
end
|
|
488
|
+
parents = [slice, *ancestors.values]
|
|
489
|
+
parents.each_with_index do |unit, index|
|
|
490
|
+
parent = parents[index + 1]
|
|
491
|
+
permitted = parent ? [parent.fetch("Id")] : []
|
|
492
|
+
unless unit["LoadState"] == "loaded" && unit["NeedDaemonReload"] == false &&
|
|
493
|
+
unit["Requires"] == permitted && unit["Wants"] == [] &&
|
|
494
|
+
(EMPTY_ACTIVATION + %w[RequiresMountsFor WantsMountsFor]).all? { |key| unit[key] == [] }
|
|
495
|
+
raise RuntimeUnavailableError, "parent activation graph could start outside its retained hierarchy"
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
permitted_paths = [@scope.fetch("root_directory"), *service.fetch("RuntimeDirectory").map { |path| "/run/#{path}" }]
|
|
499
|
+
unless prerequisites.is_a?(Hash) && prerequisites.all? { |unit, value|
|
|
500
|
+
value["Id"] == unit && value["LoadState"] == "loaded" && value["ActiveState"] == "active" && value["Job"] == [0, "/"] &&
|
|
501
|
+
(unit == "systemd-journald.socket" && value["SubState"] == "listening" ||
|
|
502
|
+
unit.end_with?(".mount") && value["SubState"] == "mounted" && (value["Where"] == "/" || path?(value["Where"])) &&
|
|
503
|
+
permitted_paths.any? { |path| covers?(value["Where"], path) })
|
|
504
|
+
} && (service.values_at("Requires", "Wants", "After").flatten.uniq - [slice["Id"]]).sort == prerequisites.keys.sort &&
|
|
505
|
+
service.values_at("Requires", "Wants").flatten.none? { |unit| unit == "systemd-journald.socket" }
|
|
506
|
+
raise RuntimeUnavailableError, "implicit backing mounts/logging are not active exact prerequisites"
|
|
507
|
+
end
|
|
508
|
+
unless parents.last["Id"] == "-.slice" && service["Slice"] == slice["Id"] &&
|
|
509
|
+
service["Requires"].include?(slice["Id"]) && service["After"].include?(slice["Id"]) &&
|
|
510
|
+
service["WantsMountsFor"] == [@scope.fetch("root_directory")] &&
|
|
511
|
+
service.fetch("RuntimeDirectory") == [@scope.fetch("runtime_directory").delete_prefix("/run/")] &&
|
|
512
|
+
service["RequiresMountsFor"].sort == service.fetch("RuntimeDirectory").map { |path| "/run/#{path}" }.sort &&
|
|
513
|
+
service["WorkingDirectory"] == (@codex_service ? @codex_intent.fetch("cwd") : "") && service["DefaultDependencies"] == false &&
|
|
514
|
+
%w[RequiredBy RequisiteOf WantedBy BoundBy UpheldBy ConsistsOf].all? { |key| service[key] == [] } &&
|
|
515
|
+
%w[disabled static].include?(service["UnitFileState"]) &&
|
|
516
|
+
EMPTY_ACTIVATION.all? { |key| service[key] == [] }
|
|
517
|
+
raise RuntimeUnavailableError, "service has outside activation or parent stop propagation"
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def verify_artifact_projection!(service, artifacts)
|
|
522
|
+
writable = service.fetch("BindPaths")
|
|
523
|
+
readonly = service.fetch("BindReadOnlyPaths")
|
|
524
|
+
mounts = writable + readonly
|
|
525
|
+
unless mounts.all? { |mount| path?(mount[0]) && path?(mount[1]) && mount[2] == false && mount[3] == 0 } &&
|
|
526
|
+
mounts.map { |mount| mount[1] }.uniq.size == mounts.size
|
|
527
|
+
raise RuntimeUnavailableError, "unit mount projection is ambiguous or optional"
|
|
528
|
+
end
|
|
529
|
+
artifacts.each do |role, entries|
|
|
530
|
+
next if %w[slice_fragment service_fragment unit_dropin].include?(role)
|
|
531
|
+
entries.each do |artifact|
|
|
532
|
+
view = artifact.fetch("view_path")
|
|
533
|
+
if IMPLICIT_NAMESPACE_ROOTS.any? { |root| covers?(root, view) } ||
|
|
534
|
+
service.fetch("ReadWritePaths").any? { |root| !path?(root) || covers?(root, view) }
|
|
535
|
+
raise RuntimeUnavailableError, "immutable artifact is inside an implicit or writable namespace projection"
|
|
536
|
+
end
|
|
537
|
+
if writable.any? { |mount| overlaps?(view, mount[1]) }
|
|
538
|
+
raise RuntimeUnavailableError, "immutable unit artifact has a writable overlay"
|
|
539
|
+
end
|
|
540
|
+
selected = readonly.select { |mount| covers?(mount[1], view) }.max_by { |mount| mount[1].length }
|
|
541
|
+
relative = selected && view.delete_prefix(selected[1]).delete_prefix("/")
|
|
542
|
+
host = selected ? (relative.empty? ? selected[0] : File.join(selected[0], relative)) : @scope.fetch("root_directory") + view
|
|
543
|
+
unless artifact.fetch("host_path") == host
|
|
544
|
+
raise RuntimeUnavailableError, "hashed artifact is shadowed by another effective unit mount"
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def verify_boundary_topology!(service, artifacts, authority:)
|
|
551
|
+
validate_staged_boundary_topology!(service: service, artifacts: artifacts, authority: authority)
|
|
552
|
+
@files.authority_socket!(authority)
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def validate_boundary_topology!(service, artifacts, authority:)
|
|
556
|
+
artifact = artifacts.fetch("boundary_manifest").first
|
|
557
|
+
bytes = @files.read(artifact.fetch("host_path"), limit: 65_536).dup.force_encoding(Encoding::UTF_8)
|
|
558
|
+
raise RuntimeUnavailableError, "boundary content is not UTF-8" unless bytes.valid_encoding?
|
|
559
|
+
boundary = JSON.parse(bytes, create_additions: false, max_nesting: 8, allow_duplicate_key: false, allow_comments: false)
|
|
560
|
+
unless boundary.is_a?(Hash) && boundary.keys.sort == %w[network_installation resources schema slot_id] &&
|
|
561
|
+
boundary["schema"] == "ace.execution-boundary-manifest/v1" && boundary["slot_id"] == @scope.fetch("slot_id") &&
|
|
562
|
+
boundary["resources"].is_a?(Array) && boundary["resources"].size.between?(1, 64)
|
|
563
|
+
raise RuntimeUnavailableError, "boundary lifecycle topology differs"
|
|
564
|
+
end
|
|
565
|
+
entries = boundary.fetch("resources")
|
|
566
|
+
ExecutionNetworkSelection.validate_static!(boundary.fetch("network_installation"), slot_id: @scope.fetch("slot_id"))
|
|
567
|
+
entries.each do |entry|
|
|
568
|
+
unless entry.is_a?(Hash) && entry.keys.sort == %w[host_path read_only stage view_path worker_visible] &&
|
|
569
|
+
path?(entry["host_path"]) && path?(entry["view_path"]) && %w[parent native].include?(entry["stage"]) &&
|
|
570
|
+
[true, false].include?(entry["worker_visible"]) && [true, false].include?(entry["read_only"]) &&
|
|
571
|
+
(entry["worker_visible"] || entry["stage"] == "parent" && entry["read_only"])
|
|
572
|
+
raise RuntimeUnavailableError, "boundary resource declaration differs"
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
unless entries.map { |entry| entry.values_at("host_path", "view_path") }.uniq.size == entries.size
|
|
576
|
+
raise RuntimeUnavailableError, "boundary resource projections repeat"
|
|
577
|
+
end
|
|
578
|
+
projections = service.fetch("BindPaths").map { |mount| [mount[0], mount[1], false] } +
|
|
579
|
+
service.fetch("BindReadOnlyPaths").map { |mount| [mount[0], mount[1], true] }
|
|
580
|
+
socket = authority.fetch("socket_path")
|
|
581
|
+
socket_projection = [socket, socket, true]
|
|
582
|
+
socket_mounts = projections.select { |host, view, _| overlaps?(host, socket) || overlaps?(view, socket) }
|
|
583
|
+
unless socket_mounts == [socket_projection]
|
|
584
|
+
raise RuntimeUnavailableError, "authority socket has no unique exact readonly projection"
|
|
585
|
+
end
|
|
586
|
+
devpts = "/run/ace/execution-slots/#{@scope.fetch('slot_id')}/devpts"
|
|
587
|
+
device_projections = @context ? [] : [[devpts, "/dev/pts", false], [devpts + "/ptmx", "/dev/pts/ptmx", true]]
|
|
588
|
+
unless projections.select { |_host, view, _| overlaps?(view, "/dev/pts") } == device_projections
|
|
589
|
+
raise RuntimeUnavailableError, "selected devpts has no exact root and ptmx projection"
|
|
590
|
+
end
|
|
591
|
+
api_roots = %w[/dev /proc /sys]
|
|
592
|
+
if (projections - device_projections).any? { |_host, view, _| api_roots.any? { |root| overlaps?(root, view) } }
|
|
593
|
+
raise RuntimeUnavailableError, "API namespace has an unsupported storage alias"
|
|
594
|
+
end
|
|
595
|
+
readonly_paths = service.fetch("ReadOnlyPaths")
|
|
596
|
+
unless readonly_paths.all? { |path| path?(path) } &&
|
|
597
|
+
%w[/dev /dev/shm].all? { |view| readonly_paths.any? { |path| covers?(path, view) } } &&
|
|
598
|
+
!service.fetch("ReadWritePaths").any? { |path| !path?(path) || overlaps?(path, "/dev/shm") }
|
|
599
|
+
raise RuntimeUnavailableError, "file-backed IPC is not positively read-only"
|
|
600
|
+
end
|
|
601
|
+
runtime = @scope.fetch("runtime_directory")
|
|
602
|
+
projections << [runtime, runtime, false]
|
|
603
|
+
service.fetch("ReadWritePaths").each do |view|
|
|
604
|
+
unless path?(view) && api_roots.none? { |root| overlaps?(root, view) }
|
|
605
|
+
raise RuntimeUnavailableError, "writable path directive is not exact"
|
|
606
|
+
end
|
|
607
|
+
projection = projections.select { |mount| covers?(mount[1], view) }.max_by { |mount| mount[1].length }
|
|
608
|
+
host = projection ? File.join(projection[0], view.delete_prefix(projection[1]).delete_prefix("/")) :
|
|
609
|
+
@scope.fetch("root_directory") + view
|
|
610
|
+
projections << [host, view, false]
|
|
611
|
+
end
|
|
612
|
+
unless projections.select { |host, view, _| overlaps?(host, socket) || overlaps?(view, socket) } == [socket_projection]
|
|
613
|
+
raise RuntimeUnavailableError, "authority socket is shadowed by an effective writable or parent projection"
|
|
614
|
+
end
|
|
615
|
+
projections.each do |host, view, readonly|
|
|
616
|
+
next if [host, view, readonly] == socket_projection
|
|
617
|
+
next if device_projections.include?([host, view, readonly])
|
|
618
|
+
next if readonly && artifacts.values.flatten.any? { |entry| entry["host_path"] == host && entry["view_path"] == view }
|
|
619
|
+
unless entries.any? { |entry| entry.values_at("host_path", "view_path", "worker_visible", "read_only") == [host, view, true, readonly] }
|
|
620
|
+
raise RuntimeUnavailableError, "effective projection is omitted from boundary inventory"
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
entries.each do |entry|
|
|
624
|
+
view = entry.fetch("view_path")
|
|
625
|
+
accessible = projections.any? { |host, target, _readonly| covers?(target, view) &&
|
|
626
|
+
File.expand_path(File.join(host, view.delete_prefix(target).delete_prefix("/"))) == entry.fetch("host_path") } ||
|
|
627
|
+
entry.fetch("host_path") == @scope.fetch("root_directory") + view
|
|
628
|
+
if entry.fetch("worker_visible") != accessible
|
|
629
|
+
raise RuntimeUnavailableError, "boundary visibility is not proven by installed topology"
|
|
630
|
+
end
|
|
631
|
+
if entry.fetch("stage") == "native" && !covers?(runtime, entry.fetch("host_path"))
|
|
632
|
+
raise RuntimeUnavailableError, "service-created resource has no fixed creation directive"
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def covers?(root, path) = root == path || root == "/" || path.start_with?(root + "/")
|
|
638
|
+
def overlaps?(left, right) = covers?(left, right) || covers?(right, left)
|
|
639
|
+
|
|
640
|
+
def verify_command!(value, executable)
|
|
641
|
+
unless value.is_a?(Array) && value.size == 1 && value.first.is_a?(Array) && value.first.size == 10 &&
|
|
642
|
+
value.first[0] == executable && value.first[1].is_a?(Array) && value.first[1].first == executable &&
|
|
643
|
+
value.first[1].all? { |arg| arg.is_a?(String) && !arg.include?("\0") } && value.first[2] == [] &&
|
|
644
|
+
value.first[3..].all? { |number| number.is_a?(Integer) }
|
|
645
|
+
raise RuntimeUnavailableError, "unit command bypasses fixed unprivileged execution"
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def credential_id(value, kind)
|
|
650
|
+
return Integer(value, 10) if value.match?(/\A[1-9][0-9]*\z/)
|
|
651
|
+
kind == :uid ? Etc.getpwnam(value).uid : Etc.getgrnam(value).gid
|
|
652
|
+
rescue ArgumentError
|
|
653
|
+
raise RuntimeUnavailableError, "installed unit principal is unavailable"
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def path?(value)
|
|
657
|
+
value.is_a?(String) && value.bytesize.between?(2, 4096) && value.start_with?("/") &&
|
|
658
|
+
!value.match?(/[\s\0;]/) && File.expand_path(value) == value
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
end
|