ace-lab 0.4.0 → 0.5.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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/docs/usage.md +10 -0
  4. data/lib/ace/lab/atoms/protected_publication_input.rb +50 -0
  5. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +114 -0
  6. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +86 -0
  7. data/lib/ace/lab/atoms/service_input.rb +20 -8
  8. data/lib/ace/lab/authorization_path.rb +20 -0
  9. data/lib/ace/lab/cli/commands/service.rb +45 -0
  10. data/lib/ace/lab/errors.rb +11 -0
  11. data/lib/ace/lab/molecules/grant_resolver.rb +2 -0
  12. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +163 -0
  13. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +165 -0
  14. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +239 -0
  15. data/lib/ace/lab/molecules/protected_publication_publisher.rb +110 -0
  16. data/lib/ace/lab/molecules/protected_service_handler.rb +9 -40
  17. data/lib/ace/lab/molecules/protected_service_policy.rb +49 -5
  18. data/lib/ace/lab/molecules/service_policy.rb +26 -0
  19. data/lib/ace/lab/organisms/authority_composition.rb +25 -4
  20. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +361 -0
  21. data/lib/ace/lab/organisms/protected_service_client.rb +129 -0
  22. data/lib/ace/lab/organisms/protected_service_listener.rb +181 -0
  23. data/lib/ace/lab/organisms/protected_service_publication.rb +268 -0
  24. data/lib/ace/lab/organisms/protected_service_receiver.rb +101 -11
  25. data/lib/ace/lab/organisms/protected_service_recovery.rb +185 -0
  26. data/lib/ace/lab/organisms/protected_service_request.rb +128 -0
  27. data/lib/ace/lab/organisms/protected_service_worker.rb +89 -0
  28. data/lib/ace/lab/version.rb +1 -1
  29. data/lib/ace/lab.rb +2 -22
  30. metadata +34 -4
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/runtime/molecules/protected_socket"
4
+ require "ace/assign/authority/transfer_codec"
5
+ require "ace/assign/atoms/evidence_digest"
6
+ require_relative "../atoms/protected_workspace_prune_input"
7
+ require_relative "../atoms/service_input"
8
+ require_relative "../atoms/protected_workspace_prune_preview"
9
+
10
+ module Ace
11
+ module Lab
12
+ module Molecules
13
+ # Fixed installed endpoint. A fresh execute connection must independently
14
+ # match the original canonical binding; identity EOF is never permission.
15
+ class ProtectedCleanupOwnerClient
16
+ PATH = "/run/lab/protected-workspace-cleanup.sock"
17
+ SCHEMA = "lab.protected-workspace-cleanup-control/v1"
18
+ LIMIT = 16_384
19
+
20
+ def initialize(observer:, scratch_root:, wire: Ace::Runtime::Molecules::ProtectedSocket)
21
+ @observer, @wire = observer, wire
22
+ @codec = Ace::Assign::Authority::TransferCodec.new(root: scratch_root)
23
+ end
24
+
25
+ def identity!
26
+ deadline = @wire.deadline(5)
27
+ connect(deadline) do |socket, binding|
28
+ @wire.write(socket, {"schema" => SCHEMA, "kind" => "identity"}, deadline: deadline, limit: LIMIT)
29
+ socket.shutdown(Socket::SHUT_WR)
30
+ guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
31
+ reply = @wire.read(guarded, deadline: deadline, limit: LIMIT)
32
+ eof!(guarded, deadline)
33
+ unless reply.is_a?(Hash) && reply.keys.sort == %w[kind operation_owner_binding schema] &&
34
+ reply["schema"] == SCHEMA && reply["kind"] == "identity" &&
35
+ digest(reply["operation_owner_binding"]) == digest(binding)
36
+ raise SecurityError, "cleanup identity reply differs from observed original owner"
37
+ end
38
+ binding
39
+ end
40
+ end
41
+
42
+ def preview!(intent:, maintenance_context:, deadline: @wire.deadline(5))
43
+ intent = Atoms::ProtectedWorkspacePrunePreview.intent!(JSON.parse(JSON.generate(intent)))
44
+ context = Atoms::ProtectedWorkspacePrunePreview.context!(JSON.parse(JSON.generate(maintenance_context)))
45
+ raise SecurityError, "preview maintenance differs" unless intent.fetch("maintenance") == context.fetch("maintenance")
46
+ connect(deadline) do |socket, _original|
47
+ @wire.write(socket, {"schema" => SCHEMA, "kind" => "preview", "intent" => intent,
48
+ "maintenance_context" => context}, deadline: deadline, limit: 65_536)
49
+ socket.shutdown(Socket::SHUT_WR)
50
+ guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
51
+ result = @wire.read(guarded, deadline: deadline, limit: LIMIT)
52
+ eof!(guarded, deadline)
53
+ Atoms::ProtectedWorkspacePrunePreview.result!(result, intent: intent, context: context)
54
+ end
55
+ end
56
+
57
+ def execute!(request:, operation_owner_binding:)
58
+ request = JSON.parse(JSON.generate(request), create_additions: false, max_nesting: 16)
59
+ fields = %w[request_id input_digest claim_binding request_event_digest dispatch_event_digest input]
60
+ Atoms::ProtectedWorkspacePruneInput.object!(request, fields)
61
+ Atoms::ProtectedWorkspacePruneInput.token!(request.fetch("request_id"))
62
+ (fields - %w[request_id input]).each { |key| Atoms::ProtectedWorkspacePruneInput.digest!(request.fetch(key)) }
63
+ bytes = JSON.generate(request.fetch("input"))
64
+ input = Atoms::ProtectedWorkspacePruneInput.parse(bytes)
65
+ unless Atoms::ServiceInput.digest(input) == request.fetch("input_digest")
66
+ raise SecurityError, "cleanup execute input differs"
67
+ end
68
+ Atoms::ProtectedWorkspacePruneInput.freeze_value(request)
69
+ deadline = @wire.deadline(300)
70
+ admission_deadline = [deadline, @wire.deadline(5)].min
71
+ connect(admission_deadline) do |socket, observed|
72
+ unless digest(observed) == digest(operation_owner_binding)
73
+ raise SecurityError, "cleanup execute owner differs from original dispatch"
74
+ end
75
+ frame = request.merge("schema" => SCHEMA, "kind" => "execute",
76
+ "operation_owner_binding_digest" => digest(observed))
77
+ @wire.write(socket, frame, deadline: admission_deadline, limit: 65_536)
78
+ socket.shutdown(Socket::SHUT_WR)
79
+ guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
80
+ reply = @wire.read(guarded, deadline: deadline, limit: LIMIT)
81
+ unless reply.is_a?(Hash) && reply.keys.sort == %w[input_digest kind receipt_ref request_id schema] &&
82
+ reply.values_at("schema", "kind", "request_id", "input_digest") ==
83
+ [SCHEMA, "result", request.fetch("request_id"), request.fetch("input_digest")]
84
+ raise SecurityError, "cleanup result identity differs"
85
+ end
86
+ ref = reply.fetch("receipt_ref")
87
+ Atoms::ProtectedWorkspacePruneInput.reference!(ref)
88
+ descriptor = {"version" => 1, "bytes" => ref.fetch("bytes"), "sha256" => ref.fetch("sha256"),
89
+ "parts" => [ref.slice("bytes", "sha256")]}
90
+ @codec.receive(guarded, descriptor: descriptor, purpose: :artifacts, deadline: deadline) do |received|
91
+ Atoms::ProtectedWorkspacePruneInput.freeze_value(ref)
92
+ {receipt_ref: ref, bytes: received.bytes.freeze}.freeze
93
+ end
94
+ end
95
+ end
96
+
97
+ def inspect!(request:, operation_owner_binding:)
98
+ request = JSON.parse(JSON.generate(request), create_additions: false, max_nesting: 16)
99
+ fields = %w[request_id input_digest claim_binding request_event_digest dispatch_event_digest input challenge_ref]
100
+ Atoms::ProtectedWorkspacePruneInput.object!(request, fields)
101
+ Atoms::ProtectedWorkspacePruneInput.token!(request.fetch("request_id"))
102
+ (fields - %w[request_id input challenge_ref]).each { |key| Atoms::ProtectedWorkspacePruneInput.digest!(request.fetch(key)) }
103
+ Atoms::ProtectedWorkspacePruneInput.object!(request.fetch("challenge_ref"), %w[challenge_event_digest])
104
+ Atoms::ProtectedWorkspacePruneInput.digest!(request.dig("challenge_ref", "challenge_event_digest"))
105
+ input = Atoms::ProtectedWorkspacePruneInput.parse(JSON.generate(request.fetch("input")))
106
+ raise SecurityError, "cleanup inspection input differs" unless Atoms::ServiceInput.digest(input) == request.fetch("input_digest")
107
+ frame = request.merge("schema" => SCHEMA, "kind" => "inspect", "operation_owner_binding_digest" => digest(operation_owner_binding))
108
+ Atoms::ProtectedWorkspacePruneInput.freeze_value(frame)
109
+ deadline = @wire.deadline(300)
110
+ admission_deadline = [deadline, @wire.deadline(5)].min
111
+ connect(admission_deadline) do |socket, _observed|
112
+ @wire.write(socket, frame, deadline: admission_deadline, limit: 65_536)
113
+ socket.shutdown(Socket::SHUT_WR)
114
+ guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
115
+ reply = @wire.read(guarded, deadline: deadline, limit: LIMIT)
116
+ success = reply.is_a?(Hash) && reply["kind"] == "completed-result"
117
+ ref_key = success ? "receipt_ref" : "inspection_ref"
118
+ unless reply.is_a?(Hash) && reply.keys.sort == (["input_digest", ref_key, "kind", "request_id", "schema"].sort) &&
119
+ reply.values_at("schema", "kind", "request_id", "input_digest") ==
120
+ [SCHEMA, success ? "completed-result" : "inspection", request.fetch("request_id"), request.fetch("input_digest")]
121
+ raise SecurityError, "cleanup inspection result differs"
122
+ end
123
+ ref = reply.fetch(ref_key)
124
+ Atoms::ProtectedWorkspacePruneInput.reference!(ref)
125
+ descriptor = {"version" => 1, "bytes" => ref.fetch("bytes"), "sha256" => ref.fetch("sha256"), "parts" => [ref.slice("bytes", "sha256")]}
126
+ @codec.receive(guarded, descriptor: descriptor, purpose: :artifacts, deadline: deadline) do |received|
127
+ Atoms::ProtectedWorkspacePruneInput.freeze_value(ref)
128
+ {kind: success ? "succeeded" : "failed-no-effect", receipt_ref: ref, bytes: received.bytes.freeze}.freeze
129
+ end
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def digest(value) = Ace::Assign::Atoms::EvidenceDigest.digest(value)
136
+
137
+ def connect(deadline)
138
+ @wire.root_path!(File.dirname(PATH), directory: true)
139
+ before = @wire.socket_identity(PATH, mode: 0o660)
140
+ raise SecurityError, "cleanup endpoint owner differs" unless before.last == 0
141
+ @wire.connect(PATH, deadline: deadline) do |socket|
142
+ observed = @observer.observe!(socket: socket, deadline: deadline)
143
+ raise SecurityError, "cleanup endpoint was replaced" unless @wire.socket_identity(PATH, mode: 0o660) == before
144
+ yield socket, observed
145
+ end
146
+ rescue IOError, SystemCallError
147
+ raise Ace::Runtime::RuntimeUnavailableError, "cleanup original transport unavailable"
148
+ end
149
+
150
+ def eof!(socket, deadline)
151
+ loop do
152
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
153
+ unless remaining.positive? && IO.select([socket], nil, nil, remaining)
154
+ raise Ace::Runtime::RuntimeUnavailableError, "cleanup reply EOF deadline expired"
155
+ end
156
+ byte = socket.read_nonblock(1, exception: false)
157
+ next if byte == :wait_readable
158
+ raise SecurityError, "cleanup reply has trailing bytes" unless byte.nil?
159
+ return
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+ require "socket"
5
+ require "ace/runtime/molecules/process_identity"
6
+ require "ace/runtime/molecules/systemd_scope_manager"
7
+ require "ace/runtime/molecules/protected_artifact_set"
8
+
9
+ module Ace
10
+ module Lab
11
+ module Molecules
12
+ # Only trusted accepted-release composition constructs this observer.
13
+ # Root policy is fixed here; ordinary ProtectedLinux stays all-zero.
14
+ class ProtectedCleanupOwnerIdentity
15
+ Runtime = Ace::Runtime::Molecules
16
+ Unavailable = Ace::Runtime::RuntimeUnavailableError
17
+ DISABLE = "--disable=gems,rubyopt,did_you_mean,error_highlight,syntax_suggest"
18
+ UNIT_FIELDS = {"Id" => "s", "LoadState" => "s", "ActiveState" => "s", "SubState" => "s",
19
+ "InvocationID" => "ay", "FragmentPath" => "s", "DropInPaths" => "as"}.freeze
20
+ EMPTY_COMMANDS = %w[ExecConditionEx ExecStartPreEx ExecStartPostEx ExecReloadEx ExecStopEx ExecStopPostEx].freeze
21
+ ENVIRONMENT = %w[PATH=/usr/bin:/bin LANG=C LC_ALL=C HOME=/root].freeze
22
+ MANAGER_ENVIRONMENT = %w[PATH LANG LANGUAGE LC_ALL LC_CTYPE LC_NUMERIC LC_TIME LC_COLLATE LC_MONETARY
23
+ LC_MESSAGES LC_PAPER LC_NAME LC_ADDRESS LC_TELEPHONE LC_MEASUREMENT LC_IDENTIFICATION].freeze
24
+ ENVIRONMENT_FIELDS = {"Environment" => "as", "EnvironmentFiles" => "a(sb)", "PassEnvironment" => "as",
25
+ "UnsetEnvironment" => "as", "PAMName" => "s"}.freeze
26
+ SERVICE_FIELDS = {**ENVIRONMENT_FIELDS, **EMPTY_COMMANDS.to_h { |key| [key, "a(sasasttttuii)"] }, "Slice" => "s", "Type" => "s", "MainPID" => "u", "ControlPID" => "u", "ExecStartEx" => "a(sasasttttuii)"}.freeze
27
+
28
+ class Kernel
29
+ def initialize(identity: Runtime::ProcessIdentity.new, pidfd_open: nil)
30
+ @identity = identity
31
+ @pidfd_open = pidfd_open || method(:open_pidfd)
32
+ end
33
+
34
+ def capture(pid)
35
+ raise Unavailable, "root owner requires Linux" unless RUBY_PLATFORM.include?("linux") && File.binread("/proc/sys/kernel/yama/ptrace_scope", 128).strip == "2"
36
+ unless pid.is_a?(Integer) && pid.positive?
37
+ raise Unavailable, "root owner PID is unavailable"
38
+ end
39
+ birth = @identity.send(:native_birth, pid)
40
+ raise Unavailable, "root owner birth is unavailable" unless birth
41
+ bytes = File.binread("/proc/#{pid}/status", 65_537)
42
+ raise Unavailable, "root owner status is oversized" if bytes.bytesize > 65_536
43
+ fields = bytes.lines.to_h { |line| line.strip.split(/:\s*/, 2) }
44
+ ids = %w[Uid Gid].map { |key| fields.fetch(key).split.map { |id| Integer(id, 10) } }
45
+ caps = %w[CapInh CapPrm CapEff CapBnd CapAmb].map { |key| Integer(fields.fetch(key), 16) }
46
+ groups = fields.fetch("Groups").split.map { |id| Integer(id, 10) }.sort
47
+ unless ids.all? { |value| value == [0, 0, 0, 0] } && groups == [0] &&
48
+ caps == [0, 2, 2, 2, 0] && fields.fetch("NoNewPrivs") == "1" &&
49
+ @identity.send(:native_birth, pid) == birth
50
+ raise Unavailable, "root owner kernel policy differs"
51
+ end
52
+ {"pid" => pid, "uid" => 0, "gid" => 0, "groups" => groups,
53
+ "started_at" => birth, "host" => Socket.gethostname,
54
+ "parent_pid" => Integer(fields.fetch("PPid"), 10)}
55
+ rescue IOError, SystemCallError, KeyError, ArgumentError
56
+ raise Unavailable, "root owner kernel policy is unavailable"
57
+ end
58
+
59
+ def pin(identity)
60
+ handle = @pidfd_open.call(identity.fetch("pid"))
61
+ unless handle.is_a?(IO) && !handle.closed?
62
+ raise Unavailable, "root owner pidfd is unavailable"
63
+ end
64
+ unless capture(identity.fetch("pid")) == identity
65
+ raise Unavailable, "root owner changed during pin acquisition"
66
+ end
67
+ handle
68
+ rescue Fiddle::DLError, SystemCallError, IOError
69
+ handle.close if handle.is_a?(IO) && !handle.closed?
70
+ raise Unavailable, "root owner pidfd capability is unavailable"
71
+ rescue Exception
72
+ handle.close if handle.is_a?(IO) && !handle.closed?
73
+ raise
74
+ end
75
+
76
+ def open_pidfd(pid)
77
+ function = Fiddle::Function.new(Fiddle::Handle::DEFAULT["pidfd_open"],
78
+ [Fiddle::TYPE_INT, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
79
+ fd = function.call(pid, 0)
80
+ raise Unavailable, "root owner pidfd is unavailable" if fd.negative?
81
+ IO.for_fd(fd, autoclose: true)
82
+ end
83
+ private :open_pidfd
84
+
85
+ def exited?(handle) = !IO.select([handle], nil, nil, 0).nil?
86
+
87
+ def peer(socket)
88
+ raise Unavailable, "root peer requires Linux" unless RUBY_PLATFORM.include?("linux")
89
+ socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_PEERCRED).data.unpack("i!3")
90
+ end
91
+ end
92
+
93
+ def initialize(unit:, slice_unit:, entry:, interpreter:, closure:, load_paths:, manager:, kernel: Kernel.new, artifacts: nil)
94
+ unless unit.is_a?(String) && Runtime::SystemdScopeManager::UNIT.match?(unit) && unit.end_with?(".service") &&
95
+ slice_unit.is_a?(String) && Runtime::SystemdScopeManager::UNIT.match?(slice_unit) && slice_unit.end_with?(".slice") &&
96
+ !%w[system.slice user.slice machine.slice].include?(slice_unit) &&
97
+ closure.is_a?(Array) && closure.size.between?(1, 4094) &&
98
+ load_paths.is_a?(Array) && load_paths.size.between?(1, 64) && load_paths.uniq == load_paths &&
99
+ load_paths.all? { |path| path.is_a?(String) && path.start_with?("/") && !path.include?("\0") && !path.include?(":") && File.expand_path(path) == path }
100
+ raise ArgumentError, "cleanup identity requires a fixed accepted-release selection"
101
+ end
102
+ @unit = unit.dup.freeze
103
+ @slice_unit = slice_unit.dup.freeze
104
+ @references = [entry, interpreter, *closure].map { |ref| reference(ref) }.freeze
105
+ unless @references.map { |ref| ref.fetch("path") }.uniq.size == @references.size
106
+ raise ArgumentError, "cleanup closure has duplicate references"
107
+ end
108
+ unless load_paths.all? { |path| @references.any? { |ref| ref.fetch("path").start_with?(path + "/") } }
109
+ raise ArgumentError, "cleanup load path is outside the held closure"
110
+ end
111
+ @entry, @interpreter = @references.first(2)
112
+ @argv = [@interpreter.fetch("path"), DISABLE, "-I", load_paths.join(":"), @entry.fetch("path")].freeze
113
+ unless @argv.sum { |argument| argument.bytesize + 1 } <= 16_384
114
+ raise ArgumentError, "cleanup startup arguments exceed bound"
115
+ end
116
+ @manager, @kernel = manager, kernel
117
+ @verification = Mutex.new
118
+ @artifacts = artifacts || Runtime::ProtectedArtifactSet.new(file_limit: 32 * 1_048_576,
119
+ total_limit: 256 * 1_048_576, count_limit: 4096)
120
+ end
121
+
122
+ def observe!(socket:, deadline: Runtime::ProtectedSocket.deadline(5))
123
+ with_verification do
124
+ observe_held!(deadline: deadline, peer_join: ->(identity) {
125
+ @kernel.peer(socket) == [identity.fetch("pid"), 0, 0]
126
+ })
127
+ end
128
+ end
129
+
130
+ # Only the immutable installed listener calls this entrypoint. Its
131
+ # positive receiver peer is not the root process being measured.
132
+ def observe_self!(deadline: Runtime::ProtectedSocket.deadline(5))
133
+ pid = Process.pid
134
+ with_verification do
135
+ observe_held!(deadline: deadline, peer_join: ->(identity) {
136
+ identity.fetch("pid") == pid
137
+ })
138
+ end
139
+ end
140
+
141
+ def with_verification
142
+ unless @verification.try_lock
143
+ raise Unavailable, "cleanup identity verification is busy"
144
+ end
145
+ begin
146
+ yield
147
+ ensure
148
+ @verification.unlock
149
+ end
150
+ end
151
+ private :with_verification
152
+
153
+ def observe_held!(deadline:, peer_join:)
154
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
155
+ unless deadline.is_a?(Numeric) && deadline.finite? && deadline > now
156
+ raise Unavailable, "cleanup identity observation deadline expired"
157
+ end
158
+ deadline = [deadline, now + 5].min
159
+ before = snapshot(deadline)
160
+ identity = @kernel.capture(before.fetch("MainPID"))
161
+ handle = @kernel.pin(identity)
162
+ @artifacts.with do |artifacts|
163
+ @references.each { |ref| artifacts.read!(ref) }
164
+ unless @references.any? { |ref| ref.fetch("path") == before.fetch("FragmentPath") }
165
+ raise Unavailable, "installed cleanup unit is outside the held closure"
166
+ end
167
+ join = @manager.unit_for_pidfd(handle: handle, timeout: remaining(deadline))
168
+ unless join == {"unit" => @unit, "invocation_id" => before.fetch("InvocationID")} &&
169
+ peer_join.call(identity) && !@kernel.exited?(handle) &&
170
+ @kernel.capture(identity.fetch("pid")) == identity && snapshot(deadline) == before
171
+ raise Unavailable, "cleanup peer differs from the original installed invocation"
172
+ end
173
+ artifacts.verify_unchanged!
174
+ unless !@kernel.exited?(handle) && @kernel.capture(identity.fetch("pid")) == identity &&
175
+ @manager.unit_for_pidfd(handle: handle, timeout: remaining(deadline)) == join
176
+ raise Unavailable, "cleanup owner changed after held source verification"
177
+ end
178
+ deep_freeze({"unit" => @unit, "invocation_id" => join.fetch("invocation_id"),
179
+ "process_binding" => identity, "entry_sha256" => @entry.fetch("sha256")})
180
+ end
181
+ ensure
182
+ handle&.close
183
+ end
184
+ private :observe_held!
185
+
186
+ private
187
+
188
+ def snapshot(deadline)
189
+ unit = @manager.typed_properties(unit: @unit, interface: "Unit", signatures: UNIT_FIELDS, timeout: remaining(deadline))
190
+ service = @manager.typed_properties(unit: @unit, interface: "Service", signatures: SERVICE_FIELDS, timeout: remaining(deadline))
191
+ environment = @manager.manager_environment(timeout: remaining(deadline))
192
+ unless environment.is_a?(Array) && environment.all? { |entry| entry.is_a?(String) && MANAGER_ENVIRONMENT.include?(entry.split("=", 2).first) } &&
193
+ service.fetch("Environment").sort == ENVIRONMENT.sort &&
194
+ %w[EnvironmentFiles PassEnvironment UnsetEnvironment].all? { |key| service.fetch(key) == [] } && service.fetch("PAMName") == ""
195
+ raise Unavailable, "cleanup original startup environment differs"
196
+ end
197
+ start = service.fetch("ExecStartEx")
198
+ unless unit.values_at("Id", "LoadState", "ActiveState", "SubState", "DropInPaths") == [@unit, "loaded", "active", "running", []] &&
199
+ unit.fetch("InvocationID").match?(/\A[0-9a-f]{32}\z/) && unit.fetch("InvocationID") != "0" * 32 &&
200
+ service.fetch("Slice") == @slice_unit &&
201
+ EMPTY_COMMANDS.all? { |key| service[key] == [] } && service.fetch("Type") == "exec" && service.fetch("MainPID").is_a?(Integer) && service.fetch("MainPID").positive? &&
202
+ service.fetch("ControlPID") == 0 && start.is_a?(Array) && start.size == 1 &&
203
+ start[0][0] == @interpreter.fetch("path") && start[0][1] == @argv && start[0][2] == [] &&
204
+ start[0][3].positive? && start[0][7] == service.fetch("MainPID") && start[0][8..9] == [0, 0]
205
+ raise Unavailable, "cleanup unit execution-start configuration differs"
206
+ end
207
+ unit.merge(service).merge("EffectiveManagerEnvironment" => environment)
208
+ rescue KeyError, NoMethodError, TypeError
209
+ raise Unavailable, "cleanup unit execution facts are unavailable"
210
+ end
211
+
212
+ def remaining(deadline)
213
+ value = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
214
+ raise Unavailable, "cleanup identity observation deadline expired" unless value.positive?
215
+ [value, 5].min
216
+ end
217
+
218
+ def reference(ref)
219
+ unless ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] &&
220
+ ref["path"].is_a?(String) && ref["path"].start_with?("/") && !ref["path"].include?("\0") && File.expand_path(ref["path"]) == ref["path"] &&
221
+ ref["sha256"].is_a?(String) && ref["sha256"].match?(/\A[0-9a-f]{64}\z/) &&
222
+ ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 32 * 1_048_576)
223
+ raise ArgumentError, "cleanup closure reference is malformed"
224
+ end
225
+ deep_freeze(ref)
226
+ end
227
+
228
+ def deep_freeze(value)
229
+ case value
230
+ when Hash then value.to_h { |key, item| [key.dup.freeze, deep_freeze(item)] }.freeze
231
+ when Array then value.map { |item| deep_freeze(item) }.freeze
232
+ when String then value.dup.freeze
233
+ else value.freeze
234
+ end
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "digest"
4
+ require "net/http"
5
+ require "timeout"
6
+ require "rubygems/package"
7
+ require "etc"
8
+ require "ace/herdr/molecules/bounded_process"
9
+ require "ace/assign/authority/private_directory"
10
+
11
+ module Ace
12
+ module Lab
13
+ module Molecules
14
+ # The admitted receiver owns permission. This boundary only invokes the
15
+ # existing single-artifact publisher and independently checks registry bytes.
16
+ class ProtectedPublicationPublisher
17
+ def initialize(process: Ace::Herdr::Molecules::BoundedProcess, http: Net::HTTP)
18
+ @process, @http = process, http
19
+ end
20
+
21
+ def verify!(publication:, artifact_digest:, platform:, deadline:)
22
+ remaining = remaining!(deadline)
23
+ path = "/api/v2/rubygems/#{publication.fetch('gem_name')}/versions/#{publication.fetch('version')}.json?platform=#{URI.encode_www_form_component(platform)}"
24
+ connection = @http.new("rubygems.org", 443, nil)
25
+ connection.use_ssl = true
26
+ connection.open_timeout = remaining
27
+ connection.read_timeout = remaining
28
+ connection.write_timeout = remaining
29
+ connection.max_retries = 0
30
+ response = nil
31
+ body = +""
32
+ Timeout.timeout(remaining, IOError) do
33
+ connection.start do |session|
34
+ connection.read_timeout = remaining!(deadline)
35
+ session.request(Net::HTTP::Get.new(path)) do |received|
36
+ response = received
37
+ received.read_body do |chunk|
38
+ remaining!(deadline)
39
+ raise IOError, "registry response exceeds bound" if body.bytesize + chunk.bytesize > 65_536
40
+ body << chunk
41
+ connection.read_timeout = remaining!(deadline)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ remaining!(deadline)
47
+ return result("absent", "registry_version_absent") if response.is_a?(Net::HTTPNotFound)
48
+ return result("uncertain", "registry_verification_unavailable") unless response.is_a?(Net::HTTPSuccess)
49
+ value = JSON.parse(body, object_class: UniqueObject, max_nesting: 16)
50
+ unless value.is_a?(Hash) && value["name"] == publication.fetch("gem_name") &&
51
+ value["version"] == publication.fetch("version") && value["platform"] == platform &&
52
+ value["sha"].is_a?(String) && value["sha"].match?(/\A[0-9a-f]{64}\z/) && value["yanked"] == false
53
+ return result("uncertain", "registry_verification_invalid")
54
+ end
55
+ value["sha"] == artifact_digest ? result("succeeded", "registry_exact_artifact") : result("failed", "registry_artifact_conflict")
56
+ rescue StandardError
57
+ result("uncertain", "registry_verification_unavailable")
58
+ end
59
+
60
+ def push!(operation:, publication:, artifact:, artifact_digest:, otp:, deadline:)
61
+ unless Process.uid == operation.fetch("executor_uid") && Process.euid == Process.uid &&
62
+ operation.fetch("argv").last == "--scoped-publication" &&
63
+ (otp.nil? || otp.is_a?(String) && otp.match?(/\A[0-9]{6}\z/))
64
+ raise SecurityError, "fixed publication executor differs"
65
+ end
66
+ environment = {"PATH" => "/usr/bin:/bin", "LANG" => "C.UTF-8", "HOME" => Etc.getpwuid(Process.uid).dir}
67
+ environment["GEM_HOST_OTP_CODE"] = otp if otp
68
+ input = {"artifact" => artifact, "artifact_digest" => artifact_digest,
69
+ "gem_name" => publication.fetch("gem_name"), "version" => publication.fetch("version")}
70
+ process = @process.call(operation.fetch("argv"), stdin_data: JSON.generate(input),
71
+ timeout_s: [remaining!(deadline), 30].min, output_limit: 1024, stderr_limit: 1024,
72
+ cleanup_group: true, environment: environment, chdir: File.dirname(artifact))
73
+ remaining!(deadline)
74
+ return result("uncertain", "publisher_reply_unavailable") if process.oversized || !process.status.success?
75
+ value = JSON.parse(process.stdout, object_class: UniqueObject, max_nesting: 4)
76
+ allowed = {"otp-required" => "provider_rejected_before_acceptance", "otp-rejected" => "provider_rejected_before_acceptance",
77
+ "otp-expired" => "provider_rejected_before_acceptance", "failed" => %w[provider_rejected_non_otp executor_credentials_unavailable invalid_artifact_selection artifact_identity_mismatch artifact_metadata_mismatch invalid_scoped_input],
78
+ "uncertain" => %w[registry_verification_required provider_result_ambiguous unclassified_push_result publisher_interrupted artifact_changed_during_push]}
79
+ unless value.is_a?(Hash) && value.keys.sort == %w[classification code] && Array(allowed[value["classification"]]).include?(value["code"])
80
+ return result("uncertain", "publisher_reply_invalid")
81
+ end
82
+ value.freeze
83
+ rescue StandardError
84
+ result("uncertain", "publisher_reply_unavailable")
85
+ ensure
86
+ environment&.delete("GEM_HOST_OTP_CODE")
87
+ end
88
+
89
+ private
90
+
91
+ class UniqueObject < Hash
92
+ def []=(key, value)
93
+ raise JSON::ParserError, "duplicate registry field" if key?(key)
94
+ super
95
+ end
96
+ end
97
+
98
+ def remaining!(deadline)
99
+ value = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
100
+ raise IOError, "publication deadline expired" unless value.positive?
101
+ value
102
+ end
103
+
104
+ def result(classification, code)
105
+ {"classification" => classification, "code" => code}.freeze
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "open3"
4
+ require "ace/herdr/molecules/bounded_process"
5
5
  require "timeout"
6
6
  require "ace/assign/authority/private_directory"
7
7
 
@@ -19,33 +19,12 @@ module Ace
19
19
  raise SecurityError, "handler executor differs"
20
20
  end
21
21
  root = Ace::Assign::Authority::PrivateDirectory.verify!(candidate_root)
22
- out = nil
23
- Open3.popen3({"PATH" => "/usr/bin:/bin", "LANG" => "C.UTF-8", "HOME" => root},
24
- *operation.fetch("argv"), chdir: root, pgroup: true, unsetenv_others: true) do |stdin, stdout, stderr, waiter|
25
- readers = []
26
- begin
27
- Timeout.timeout(30) do
28
- readers = [Thread.new { bounded(stdout, MAX_STDOUT) }, Thread.new { bounded(stderr, MAX_STDERR) }]
29
- stdin.write(JSON.generate(envelope))
30
- stdin.close
31
- output, errors = readers.map(&:value)
32
- unless output && errors && waiter.value.success?
33
- return nil
34
- end
35
- out = output
36
- end
37
- rescue Timeout::Error, IOError, SystemCallError
38
- return nil
39
- ensure
40
- stdin.close unless stdin.closed?
41
- # This group is created and owned by this receiver. Termination
42
- # is cleanup, never proof that an arbitrary handler left no effect.
43
- Process.kill("KILL", -waiter.pid) rescue nil
44
- waiter.value
45
- [stdout, stderr].each { |io| io.close unless io.closed? }
46
- readers.each(&:join)
47
- end
48
- end
22
+ result = Ace::Herdr::Molecules::BoundedProcess.call(operation.fetch("argv"),
23
+ stdin_data: JSON.generate(envelope), timeout_s: 30,
24
+ output_limit: MAX_STDOUT, stderr_limit: MAX_STDERR, cleanup_group: true,
25
+ environment: {"PATH" => "/usr/bin:/bin", "LANG" => "C.UTF-8", "HOME" => root}, chdir: root)
26
+ return nil if result.oversized || !result.status.success?
27
+ out = result.stdout
49
28
  response = JSON.parse(out)
50
29
  request = envelope.fetch("request")
51
30
  unless response.is_a?(Hash) && response.keys.sort == %w[evidence input_digest outcome request_id] &&
@@ -54,19 +33,9 @@ module Ace
54
33
  return nil
55
34
  end
56
35
  response
57
- rescue JSON::ParserError
58
- nil
59
- end
60
-
61
- private
62
-
63
- def bounded(io, limit)
64
- bytes = io.read(limit + 1).to_s
65
- bytes.bytesize <= limit ? bytes : nil
66
- rescue IOError, SystemCallError
36
+ rescue JSON::ParserError, Timeout::Error, Ace::Herdr::Molecules::BoundedProcess::PostLaunchError,
37
+ IOError, SystemCallError
67
38
  nil
68
- ensure
69
- io.close unless io.closed?
70
39
  end
71
40
  end
72
41
  end